From 9c7daf6ead06d5e17b8abdcf8d0e8bc54fe31155 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 17:15:22 +0100 Subject: [PATCH 01/44] fix: align single-chat session header with tabs Match the session header's default and compact heights and separator gutter to the chat tab strip while preserving its content alignment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/media/chatCompositeBar.css | 13 +++- .../test/browser/sessionHeader.test.ts | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 5202a1222345f6..714f15a4df2ad1 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -35,7 +35,15 @@ flex-direction: row; align-items: flex-start; gap: 6px; + height: var(--vscode-spacing-size320, 32px); + margin: 0 calc(-1 * var(--vscode-spacing-size80)); + padding: 0 var(--vscode-spacing-size80); border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); + box-sizing: border-box; +} + +.agent-sessions-workbench.editor-tabs-compact-height .chat-composite-bar.session-header-bar .chat-composite-bar-header { + height: var(--vscode-spacing-size280, 28px); } /* Main column hosts the title row. */ @@ -45,6 +53,7 @@ gap: 2px; flex: 1 1 auto; min-width: 0; + height: 100%; } /* Title row: title + actions */ @@ -52,7 +61,7 @@ display: flex; align-items: center; gap: 6px; - height: 34px; + height: 100%; } /* Status icon column — sits beside the main column, centered on the title line. @@ -63,7 +72,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - height: 34px; + height: 100%; font-size: var(--vscode-codiconFontSize, 16px); color: var(--session-view-foreground); } diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 1392a046e484af..0f18e702115ac6 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -122,6 +122,67 @@ suite('Sessions - SessionHeader', () => { }); }); + test('matches the default and compact editor tab strip geometry', () => { + const { header } = createHarness(disposables); + const container = header.element.parentElement!; + container.classList.add('agent-sessions-workbench'); + container.style.setProperty('--vscode-spacing-size80', '8px'); + container.style.setProperty('--vscode-spacing-size100', '10px'); + container.style.width = '420px'; + mainWindow.document.body.appendChild(container); + + try { + const headerRow = header.element.querySelector('.chat-composite-bar-header')!; + const getGeometry = () => { + const barBounds = header.element.getBoundingClientRect(); + const headerBounds = headerRow.getBoundingClientRect(); + return { + barHeight: mainWindow.getComputedStyle(header.element).height, + headerHeight: mainWindow.getComputedStyle(headerRow).height, + headerInset: headerBounds.left - barBounds.left, + barPaddingInline: mainWindow.getComputedStyle(header.element).paddingInline, + headerPaddingInline: mainWindow.getComputedStyle(headerRow).paddingInline, + hasCompactClass: container.classList.contains('editor-tabs-compact-height'), + }; + }; + + const defaultGeometry = getGeometry(); + container.classList.add('editor-tabs-compact-height'); + const compactGeometry = getGeometry(); + container.classList.remove('editor-tabs-compact-height'); + const restoredGeometry = getGeometry(); + + assert.deepStrictEqual({ defaultGeometry, compactGeometry, restoredGeometry }, { + defaultGeometry: { + barHeight: '32px', + headerHeight: '32px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '8px', + hasCompactClass: false, + }, + compactGeometry: { + barHeight: '28px', + headerHeight: '28px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '8px', + hasCompactClass: true, + }, + restoredGeometry: { + barHeight: '32px', + headerHeight: '32px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '8px', + hasCompactClass: false, + }, + }); + } finally { + container.remove(); + } + }); + test('reports whether the inline rename could be started', () => { const renameable = createHarness(disposables, { supportsMultipleChats: false, supportsRename: true }); const notRenameable = createHarness(disposables); From b8436fa82904167296242399345c86c60242f83d Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 17:13:41 +0100 Subject: [PATCH 02/44] fix: add accessible empty-state headings Use native headings for shared Sessions and Browser empty states while preserving their visual spacing. Exercise the production Browser renderer in the styling regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/media/sessionsEmptyState.css | 1 + .../browser/parts/sessionsEmptyState.ts | 2 +- .../sessions/test/browser/editorPart.test.ts | 62 +++++++++++++++++-- .../browserView/browser/browserWelcome.ts | 2 +- .../browser/media/browserWelcome.css | 1 + 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/browser/parts/media/sessionsEmptyState.css b/src/vs/sessions/browser/parts/media/sessionsEmptyState.css index 63e2844e0161b7..ebff580e9daf5c 100644 --- a/src/vs/sessions/browser/parts/media/sessionsEmptyState.css +++ b/src/vs/sessions/browser/parts/media/sessionsEmptyState.css @@ -22,6 +22,7 @@ .sessions-empty-state-title { color: var(--vscode-foreground); font-weight: var(--vscode-fontWeight-semiBold); + margin: 0; } .sessions-empty-state-description { diff --git a/src/vs/sessions/browser/parts/sessionsEmptyState.ts b/src/vs/sessions/browser/parts/sessionsEmptyState.ts index 131a590aa2a8a5..a9430c5a61d55d 100644 --- a/src/vs/sessions/browser/parts/sessionsEmptyState.ts +++ b/src/vs/sessions/browser/parts/sessionsEmptyState.ts @@ -12,7 +12,7 @@ import * as dom from '../../../base/browser/dom.js'; export function renderSessionsEmptyState(parent: HTMLElement, title: string, description: string): HTMLElement { const container = dom.append(parent, dom.$('.sessions-empty-state')); - const titleElement = dom.append(container, dom.$('.sessions-empty-state-title')); + const titleElement = dom.append(container, dom.$('h2.sessions-empty-state-title')); titleElement.textContent = title; const descriptionElement = dom.append(container, dom.$('.sessions-empty-state-description')); diff --git a/src/vs/sessions/test/browser/editorPart.test.ts b/src/vs/sessions/test/browser/editorPart.test.ts index cdc5f021ef915a..c0e74d1bd52714 100644 --- a/src/vs/sessions/test/browser/editorPart.test.ts +++ b/src/vs/sessions/test/browser/editorPart.test.ts @@ -6,6 +6,9 @@ import assert from 'assert'; import { mainWindow } from '../../../base/browser/window.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +// eslint-disable-next-line local/code-import-patterns +import { createBrowserWelcome } from '../../../workbench/contrib/browserView/browser/browserWelcome.js'; +import { renderSessionsEmptyState } from '../../browser/parts/sessionsEmptyState.js'; import '../../browser/parts/media/editorPart.css'; function appendElement(parent: HTMLElement, className: string): HTMLElement { @@ -50,6 +53,41 @@ suite('Sessions - EditorPart', () => { } }); + test('uses a semantic heading for shared empty states', () => { + const host = appendElement(mainWindow.document.body, 'agent-sessions-workbench'); + + try { + const container = renderSessionsEmptyState(host, 'Files', 'Select a file from the Files view'); + const title = container.querySelector('.sessions-empty-state-title'); + const description = container.querySelector('.sessions-empty-state-description'); + assert.ok(title && description); + + assert.deepStrictEqual({ + contentChildren: Array.from(container.children, element => element.className), + title: { + tagName: title.tagName, + textContent: title.textContent, + margin: mainWindow.getComputedStyle(title).margin, + }, + description: { + textContent: description.textContent, + }, + }, { + contentChildren: ['sessions-empty-state-title', 'sessions-empty-state-description'], + title: { + tagName: 'H2', + textContent: 'Files', + margin: '0px', + }, + description: { + textContent: 'Select a file from the Files view', + }, + }); + } finally { + host.remove(); + } + }); + test('uses the shared empty-state hierarchy for Browser', () => { const workbench = appendElement(mainWindow.document.body, 'monaco-workbench agent-sessions-workbench'); workbench.style.setProperty('--vscode-spacing-size40', '4px'); @@ -60,20 +98,30 @@ suite('Sessions - EditorPart', () => { workbench.style.setProperty('--vscode-descriptionForeground', 'rgb(157, 157, 157)'); const editorPart = appendElement(workbench, 'part editor'); - const content = appendElement(editorPart, 'browser-welcome-content'); - const icon = appendElement(content, 'browser-welcome-icon'); - const title = appendElement(content, 'browser-welcome-title'); - const subtitle = appendElement(content, 'browser-welcome-subtitle'); + const browserRoot = appendElement(editorPart, 'browser-root'); try { + const container = createBrowserWelcome('Browser', 'Use Add Element to Chat to reference UI elements in chat prompts.'); + browserRoot.appendChild(container); + + const content = container.querySelector('.browser-welcome-content'); + const icon = content?.querySelector('.browser-welcome-icon'); + const title = content?.querySelector('.browser-welcome-title'); + const subtitle = content?.querySelector('.browser-welcome-subtitle'); + assert.ok(content && icon && title && subtitle); + const contentStyle = mainWindow.getComputedStyle(content); const titleStyle = mainWindow.getComputedStyle(title); const subtitleStyle = mainWindow.getComputedStyle(subtitle); assert.deepStrictEqual({ + containerChildren: Array.from(container.children, element => element.className), + contentChildren: Array.from(content.children, element => element.className), gap: contentStyle.gap, iconDisplay: mainWindow.getComputedStyle(icon).display, title: { + tagName: title.tagName, + textContent: title.textContent, color: titleStyle.color, fontSize: titleStyle.fontSize, fontWeight: titleStyle.fontWeight, @@ -81,6 +129,7 @@ suite('Sessions - EditorPart', () => { padding: titleStyle.padding, }, subtitle: { + textContent: subtitle.textContent, color: subtitleStyle.color, fontSize: subtitleStyle.fontSize, fontWeight: subtitleStyle.fontWeight, @@ -88,9 +137,13 @@ suite('Sessions - EditorPart', () => { padding: subtitleStyle.padding, }, }, { + containerChildren: ['browser-welcome-content'], + contentChildren: ['browser-welcome-icon', 'browser-welcome-title', 'browser-welcome-subtitle'], gap: '4px', iconDisplay: 'none', title: { + tagName: 'H2', + textContent: 'Browser', color: 'rgb(204, 204, 204)', fontSize: '13px', fontWeight: '600', @@ -98,6 +151,7 @@ suite('Sessions - EditorPart', () => { padding: '0px', }, subtitle: { + textContent: 'Use Add Element to Chat to reference UI elements in chat prompts.', color: 'rgb(157, 157, 157)', fontSize: '13px', fontWeight: '400', diff --git a/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts b/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts index c59001596c7f9f..ef36e2f7a8f12e 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts @@ -19,7 +19,7 @@ export function createBrowserWelcome(title: string, subtitle: string): HTMLEleme iconContainer.appendChild(renderIcon(Codicon.globe)); content.appendChild(iconContainer); - const titleElement = $('.browser-welcome-title'); + const titleElement = $('h2.browser-welcome-title'); titleElement.textContent = title; content.appendChild(titleElement); diff --git a/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css b/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css index c5fa70bd4d3de4..5339c5ab52e017 100644 --- a/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css +++ b/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css @@ -36,6 +36,7 @@ font-size: 13px; font-weight: 600; color: var(--vscode-foreground); + margin: 0; margin-top: 5px; text-align: center; line-height: normal; From 7eab850f2b256e229d9c5c0757deb68dc430bd18 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 17:19:06 +0100 Subject: [PATCH 03/44] fix: adjust padding for session header bar --- src/vs/sessions/browser/parts/media/chatCompositeBar.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 714f15a4df2ad1..580b6c40e4a044 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -37,7 +37,7 @@ gap: 6px; height: var(--vscode-spacing-size320, 32px); margin: 0 calc(-1 * var(--vscode-spacing-size80)); - padding: 0 var(--vscode-spacing-size80); + padding: 0 var(--vscode-spacing-size20); border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); box-sizing: border-box; } From a5b0d743e3b5104ebeac6adbcf6fe144108a2db3 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 17:24:11 +0100 Subject: [PATCH 04/44] fix: add high contrast session header separator Use the contrast border for the single-chat header in high-contrast themes and cover it alongside the current header geometry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/media/chatCompositeBar.css | 4 ++++ .../sessions/test/browser/sessionHeader.test.ts | 15 ++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 580b6c40e4a044..eaec9061daedba 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -42,6 +42,10 @@ box-sizing: border-box; } +:is(.hc-black, .hc-light) .chat-composite-bar.session-header-bar .chat-composite-bar-header { + border-bottom-color: var(--vscode-contrastBorder); +} + .agent-sessions-workbench.editor-tabs-compact-height .chat-composite-bar.session-header-bar .chat-composite-bar-header { height: var(--vscode-spacing-size280, 28px); } diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 0f18e702115ac6..e59cbee45a630f 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -122,12 +122,14 @@ suite('Sessions - SessionHeader', () => { }); }); - test('matches the default and compact editor tab strip geometry', () => { + test('matches the editor tab strip geometry and high contrast separator', () => { const { header } = createHarness(disposables); const container = header.element.parentElement!; container.classList.add('agent-sessions-workbench'); + container.style.setProperty('--vscode-spacing-size20', '2px'); container.style.setProperty('--vscode-spacing-size80', '8px'); container.style.setProperty('--vscode-spacing-size100', '10px'); + container.style.setProperty('--vscode-contrastBorder', 'rgb(1, 2, 3)'); container.style.width = '420px'; mainWindow.document.body.appendChild(container); @@ -151,14 +153,16 @@ suite('Sessions - SessionHeader', () => { const compactGeometry = getGeometry(); container.classList.remove('editor-tabs-compact-height'); const restoredGeometry = getGeometry(); + container.classList.add('hc-black'); + const highContrastSeparatorColor = mainWindow.getComputedStyle(headerRow).borderBottomColor; - assert.deepStrictEqual({ defaultGeometry, compactGeometry, restoredGeometry }, { + assert.deepStrictEqual({ defaultGeometry, compactGeometry, restoredGeometry, highContrastSeparatorColor }, { defaultGeometry: { barHeight: '32px', headerHeight: '32px', headerInset: 2, barPaddingInline: '10px', - headerPaddingInline: '8px', + headerPaddingInline: '2px', hasCompactClass: false, }, compactGeometry: { @@ -166,7 +170,7 @@ suite('Sessions - SessionHeader', () => { headerHeight: '28px', headerInset: 2, barPaddingInline: '10px', - headerPaddingInline: '8px', + headerPaddingInline: '2px', hasCompactClass: true, }, restoredGeometry: { @@ -174,9 +178,10 @@ suite('Sessions - SessionHeader', () => { headerHeight: '32px', headerInset: 2, barPaddingInline: '10px', - headerPaddingInline: '8px', + headerPaddingInline: '2px', hasCompactClass: false, }, + highContrastSeparatorColor: 'rgb(1, 2, 3)', }); } finally { container.remove(); From 1861d9215dafa69aa085c36f7e6675c20693c9b4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 12:32:41 -0400 Subject: [PATCH 05/44] voice: preserve mode when creating agent sessions (#334071) * voice: preserve mode when creating agent sessions Route voice-requested new sessions through the active host so the Agents window can create a provider-backed draft without disconnecting Voice Mode. Preserve the existing local Chat fallback and report host preparation failures honestly.\n\nFixes #334059\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * voice: send new agent session requests directly Avoid racing the newly mounted composer after creating a provider-backed session. Create and send through the sessions service as one awaited operation, and report preparation failures without falling back to another session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * voice: use the new-session send lifecycle Send the first voice request through the draft-session API so the provider creates and graduates the session before routing subsequent requests.\n\nFixes #334059\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * voice: preserve active controls after session creation Keep the segmented Voice Mode controls bound to a pinned session target when the new-session composer is replaced and focus is temporarily cleared. Fixes #334059 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * voice: harden new session preparation Restore prior routing when host preparation fails, distinguish voice-initiated composer transitions from draft ownership, and prevent registered host failures from falling back to local Chat. Fixes #334059 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * voice: refresh controls when sessions materialize Observe Chat widget view-model changes so Voice Mode controls recompute when a draft becomes the pinned provider session. Fixes #334059 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatVoice.ts | 24 +++ .../chat/browser/voiceBridge.contribution.ts | 78 ++++++++- .../chat/test/browser/voiceBridge.test.ts | 158 ++++++++++++++++- .../voiceClient/voiceSessionController.ts | 57 +++++-- .../browser/widget/input/chatInputPart.ts | 10 +- .../voiceSessionController.test.ts | 159 +++++++++++++++++- 6 files changed, 454 insertions(+), 32 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts index 9d0214507f0a3b..4bbd91e5c37bbb 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts @@ -78,6 +78,10 @@ export interface INewChatVoiceTargetService { registerComposer(composer: INewChatVoiceComposer): IDisposable; /** Promote `composer` to the active voice target. */ setActive(composer: INewChatVoiceComposer): void; + /** Allow the next composer replacement initiated by a voice request. */ + beginVoiceTransition(): IDisposable; + /** Consume a pending voice-initiated composer replacement. */ + consumeVoiceTransition(): boolean; } export class NewChatVoiceTargetService extends Disposable implements INewChatVoiceTargetService { @@ -86,6 +90,8 @@ export class NewChatVoiceTargetService extends Disposable implements INewChatVoi private readonly _composers = new Set(); private readonly _activeComposer = observableValue(this, undefined); readonly activeComposer: IObservable = this._activeComposer; + private _voiceTransitionVersion = 0; + private _pendingVoiceTransition: number | undefined; /** Session resource of the last-focused chat widget (the last-focused input). */ private readonly _focusedSessionResource: IObservable; @@ -136,6 +142,24 @@ export class NewChatVoiceTargetService extends Disposable implements INewChatVoi this._activeComposer.set(composer, undefined); } } + + beginVoiceTransition(): IDisposable { + const version = ++this._voiceTransitionVersion; + this._pendingVoiceTransition = version; + return toDisposable(() => { + if (this._pendingVoiceTransition === version) { + this._pendingVoiceTransition = undefined; + } + }); + } + + consumeVoiceTransition(): boolean { + if (this._pendingVoiceTransition === undefined) { + return false; + } + this._pendingVoiceTransition = undefined; + return true; + } } registerSingleton(INewChatVoiceTargetService, NewChatVoiceTargetService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts index 4dfe117efd3675..c5011ea2a1ecf7 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts @@ -3,21 +3,69 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; +import { IVoiceSessionController, VoiceNewSessionPreparationResult } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceInputUtils.js'; import { IVoiceModelSelectionResult, resolveVoiceModel } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, inheritableSessionTarget, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTINEL } from './newChatVoice.js'; +export async function prepareNewVoiceSession( + text: string, + sessionsService: ISessionsService, + sessionsManagementService: ISessionsManagementService, + voiceSessionController: IVoiceSessionController, + hasActiveComposer: () => boolean, + beginVoiceTransition: () => IDisposable, + logService: ILogService, +): Promise { + const activeSession = sessionsService.activeSession.get(); + const isQuickChat = activeSession?.isQuickChat?.get() ?? false; + const folderUri = isQuickChat ? undefined : activeSession?.workspace.get()?.uri; + const previousTarget = voiceSessionController.targetSession.get(); + const previousHadDraftTarget = voiceSessionController.hasDraftTarget.get(); + const restoreVoiceTarget = () => previousHadDraftTarget + ? voiceSessionController.setDraftTarget() + : voiceSessionController.setTargetSession(previousTarget); + const fail = (): VoiceNewSessionPreparationResult => { + restoreVoiceTarget(); + return 'failed'; + }; + voiceSessionController.setDraftTarget(); + const transition = beginVoiceTransition(); + try { + const result = await sessionsService.openNewSession({ + folderUri, + ...inheritableSessionTarget(sessionsManagementService, activeSession, folderUri), + }); + if (folderUri) { + if (!result.session) { + return fail(); + } + if (text.trim()) { + await sessionsManagementService.sendNewChatRequest(result.session, { query: text }); + return 'sent'; + } + return 'prepared'; + } + return sessionsService.activeSession.get() === undefined && hasActiveComposer() ? 'prepared' : fail(); + } catch (error) { + logService.error('Failed to prepare a new session for Voice Mode:', error); + return fail(); + } finally { + transition.dispose(); + } +} + /** * Bridges {@link IVoiceSessionController} to Agents window chat surfaces. * The shared controller uses `_chat.voice.*` commands; Agents hosts chats @@ -26,6 +74,7 @@ import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTI * Commands are registered only while `agents.voice.enabled` is set: * - `_chat.voice.acceptInput` injects transcribed text into the focused chat widget. * - `_chat.voice.getCurrentSession` reports the active session's chat resource. + * - `_chat.voice.prepareNewSession` creates a provider-backed draft for a new-session request. * - `_chat.voice.switchToSession` activates the session that owns a chat resource. * - `_chat.voice.activateSession` narrates a session's pending voice item on demand. */ @@ -42,6 +91,7 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, + @ILogService private readonly logService: ILogService, ) { super(); @@ -99,6 +149,18 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo return this.chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource?.toString(); })); + this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', (_accessor, text: string) => + prepareNewVoiceSession( + text, + this.sessionsService, + this.sessionsManagementService, + this.voiceSessionController, + () => !!this._activeComposerTarget(), + () => this.newChatVoiceTargetService.beginVoiceTransition(), + this.logService, + ) + )); + this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.selectModel', (_accessor, requestedModel: string): IVoiceModelSelectionResult => { const composer = this._activeComposerTarget(); const widget = composer ? undefined : this._activeSessionWidget() ?? this.chatWidgetService.lastFocusedWidget; @@ -344,10 +406,12 @@ export class SessionsVoiceNewComposerContribution extends Disposable implements voiceComposerCaptured = true; return; } - // A different welcome composer took over while voice is connected: the - // connection is bound to the previous surface and can't route here. - if (activeComposer && activeComposer !== voiceComposer && !activeComposer.routesWhileSessionActive) { - voiceSessionController.disconnect('internal'); + if (activeComposer && activeComposer !== voiceComposer) { + if (newChatVoiceTargetService.consumeVoiceTransition()) { + voiceComposer = activeComposer; + } else if (!activeComposer.routesWhileSessionActive) { + voiceSessionController.disconnect('internal'); + } } })); } diff --git a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts index f2915ab8098062..72dbc9878f1ecc 100644 --- a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts @@ -6,14 +6,17 @@ import assert from 'assert'; import { Event } from '../../../../../base/common/event.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; -import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { INewChatVoiceComposer, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; -import { SessionsVoiceNewComposerContribution } from '../../browser/voiceBridge.contribution.js'; +import { prepareNewVoiceSession, SessionsVoiceNewComposerContribution } from '../../browser/voiceBridge.contribution.js'; suite('SessionsVoiceNewComposerContribution', () => { @@ -33,12 +36,14 @@ suite('SessionsVoiceNewComposerContribution', () => { function createController(isConnected: ISettableObservable, isConnecting = constObservable(false)) { let disconnectCount = 0; + const hasDraftTarget = observableValue('hasDraftTarget', false); const controller = new class extends mock() { override readonly isConnected = isConnected; override readonly isConnecting = isConnecting; + override readonly hasDraftTarget = hasDraftTarget; override disconnect(): void { disconnectCount++; } }; - return { controller, getDisconnectCount: () => disconnectCount }; + return { controller, hasDraftTarget, getDisconnectCount: () => disconnectCount }; } function createTarget(): NewChatVoiceTargetService { @@ -69,6 +74,41 @@ suite('SessionsVoiceNewComposerContribution', () => { assert.strictEqual(getDisconnectCount(), 1); }); + test('keeps voice connected when voice creates a fresh session composer', () => { + const target = disposables.add(createTarget()); + const isConnected = observableValue('isConnected', false); + const { controller, getDisconnectCount } = createController(isConnected); + + const a = composer(); + disposables.add(target.registerComposer(a)); + isConnected.set(true, undefined); + disposables.add(new SessionsVoiceNewComposerContribution(controller, target)); + + const transition = target.beginVoiceTransition(); + const b = composer(); + disposables.add(target.registerComposer(b)); + transition.dispose(); + + assert.strictEqual(getDisconnectCount(), 0); + }); + + test('disconnects for an unrelated composer even when voice owns a draft', () => { + const target = disposables.add(createTarget()); + const isConnected = observableValue('isConnected', false); + const { controller, hasDraftTarget, getDisconnectCount } = createController(isConnected); + + const a = composer(); + disposables.add(target.registerComposer(a)); + hasDraftTarget.set(true, undefined); + isConnected.set(true, undefined); + disposables.add(new SessionsVoiceNewComposerContribution(controller, target)); + + const b = composer(); + disposables.add(target.registerComposer(b)); + + assert.strictEqual(getDisconnectCount(), 1); + }); + test('disconnects when a fresh welcome composer takes over a connecting voice session', () => { const target = disposables.add(createTarget()); const isConnected = observableValue('isConnected', false); @@ -116,4 +156,116 @@ suite('SessionsVoiceNewComposerContribution', () => { assert.strictEqual(getDisconnectCount(), 0); }); + + test('creates and sends a voice-requested session without waiting for its composer', async () => { + const workspace = new class extends mock() { + override readonly uri = URI.file('/workspace'); + }(); + const activeSession = new class extends mock() { + override readonly workspace = constObservable(workspace); + override readonly isQuickChat = constObservable(false); + }(); + const chat = new class extends mock() { }(); + const createdSession = new class extends mock() { + override readonly mainChat = constObservable(chat); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override async openNewSession() { + return { session: createdSession, trustDeclined: false }; + } + }(); + const sent: { session: ISession; query: string }[] = []; + const sessionsManagementService = new class extends mock() { + override isNewSessionTargetAvailable(): boolean { return false; } + override async sendNewChatRequest(session: ISession, options: { query: string }): Promise { + sent.push({ session, query: options.query }); + } + }(); + const targetSession = observableValue('targetSession', URI.parse('agent-host-copilot:/existing')); + const hasDraftTarget = observableValue('hasDraftTarget', false); + const voiceSessionController = new class extends mock() { + override readonly targetSession = targetSession; + override readonly hasDraftTarget = hasDraftTarget; + override setDraftTarget(): void { + targetSession.set(undefined, undefined); + hasDraftTarget.set(true, undefined); + } + override setTargetSession(resource: URI | undefined): void { + hasDraftTarget.set(false, undefined); + targetSession.set(resource, undefined); + } + }(); + + const result = await prepareNewVoiceSession( + 'refactor the upload service', + sessionsService, + sessionsManagementService, + voiceSessionController, + () => false, + () => ({ dispose() { } }), + new NullLogService(), + ); + + assert.deepStrictEqual({ + result, + hasDraftTarget: hasDraftTarget.get(), + sent, + }, { + result: 'sent', + hasDraftTarget: true, + sent: [{ session: createdSession, query: 'refactor the upload service' }], + }); + }); + + test('restores the previous voice target when new-session preparation is declined', async () => { + const previousTarget = URI.parse('agent-host-copilot:/existing'); + const workspace = new class extends mock() { + override readonly uri = URI.file('/workspace'); + }(); + const activeSession = new class extends mock() { + override readonly workspace = constObservable(workspace); + override readonly isQuickChat = constObservable(false); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override async openNewSession() { + return { session: undefined, trustDeclined: true }; + } + }(); + const targetSession = observableValue('targetSession', previousTarget); + const hasDraftTarget = observableValue('hasDraftTarget', false); + const voiceSessionController = new class extends mock() { + override readonly targetSession = targetSession; + override readonly hasDraftTarget = hasDraftTarget; + override setDraftTarget(): void { + targetSession.set(undefined, undefined); + hasDraftTarget.set(true, undefined); + } + override setTargetSession(resource: URI | undefined): void { + hasDraftTarget.set(false, undefined); + targetSession.set(resource, undefined); + } + }(); + + const result = await prepareNewVoiceSession( + 'refactor the upload service', + sessionsService, + new class extends mock() { }(), + voiceSessionController, + () => false, + () => ({ dispose() { } }), + new NullLogService(), + ); + + assert.deepStrictEqual({ + result, + targetSession: targetSession.get()?.toString(), + hasDraftTarget: hasDraftTarget.get(), + }, { + result: 'failed', + targetSession: previousTarget.toString(), + hasDraftTarget: false, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 2719423a5a0ae7..3bfedfb39c91e6 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -320,6 +320,19 @@ export interface IVoiceSessionController { export const IVoiceSessionController = createDecorator('voiceSessionController'); +export type VoiceNewSessionPreparationResult = 'prepared' | 'sent' | 'failed'; + +/** Whether a chat input owns the current Voice Mode session. */ +export function isVoiceSessionActiveForInput(inputFocused: boolean, targetSession: URI | undefined, hasDraftTarget: boolean, sessionResource: URI | undefined): boolean { + if (hasDraftTarget) { + return false; + } + if (targetSession) { + return !!sessionResource && isEqual(targetSession, sessionResource); + } + return inputFocused; +} + export class VoiceSessionController extends Disposable implements IVoiceSessionController { declare readonly _serviceBrand: undefined; @@ -2019,15 +2032,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (text !== rawText && e.args) { e.args['text'] = text; } - if (e.args?.['new_session'] === true) { - // Pin this submission to the new target so it outranks any - // stale focus-change pin. - this._setPinnedSubmitSession(undefined); - this.newSessionAsTarget(); - if (text.trim()) { - this._setPinnedSubmitSession(this._targetSession.get()); - } - } + const createNewSession = e.args?.['new_session'] === true; this._statusText.set(VoiceToolDispatchService.getActionLabel(e.name), undefined); this._persistEntry('agent_tool_call', this._renderToolCallSummary(e.name, e.args), { toolName: e.name, @@ -2043,9 +2048,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._sendContext(); this.voiceClientService.sendToolResult(e.callId, result); }; - const sendPromise = shouldSend - ? this._sendTranscriptionToChat(text) - : Promise.resolve(false); + const sendPromise = this._prepareNewSessionTarget(createNewSession, text).then(result => { + if (result === 'failed') { + return false; + } + if (result === 'sent' || !shouldSend) { + return true; + } + return this._sendTranscriptionToChat(text); + }); sendPromise.then(sent => { if (!sent) { this._clearAwaitingReply(); @@ -3688,6 +3699,28 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + private async _prepareNewSessionTarget(createNewSession: boolean, text: string): Promise { + if (!createNewSession) { + return 'prepared'; + } + + this._setPinnedSubmitSession(undefined); + if (CommandsRegistry.getCommand('_chat.voice.prepareNewSession')) { + try { + return await this.commandService.executeCommand('_chat.voice.prepareNewSession', text) ?? 'failed'; + } catch (error) { + this.logService.error('Failed to prepare a host session for Voice Mode:', error); + return 'failed'; + } + } + + this.newSessionAsTarget(); + if (text.trim()) { + this._setPinnedSubmitSession(this._targetSession.get()); + } + return 'prepared'; + } + /** * Watch a session's latest response and surface it in the floating window * transcript. Called when voice sends to a non-visible session so the user 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 18ace5d118e554..471aebdc9428a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -121,7 +121,7 @@ import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../speechToT import { IDictationOnboardingService } from '../../speechToText/dictationOnboarding.js'; import { isDictationActiveForEditor, notifyDictationSubmitted, onDidChangeDictationEditor } from '../../speechToText/dictationSession.js'; import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; -import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; +import { isVoiceSessionActiveForInput, IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { getAgentSessionPullRequestContextValue } from '../../agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; @@ -3445,15 +3445,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const { location } = this.getWidgetLocationInfo(widget); const focusedWidget = observableFromEvent(this, this.chatWidgetService.onDidChangeFocusedSession, () => this.chatWidgetService.lastFocusedWidget); + const voiceSessionResource = observableFromEvent(this, widget.onDidChangeViewModel, () => widget.viewModel?.sessionResource); const isVoiceInputActive = derived(this, reader => focusedWidget.read(reader) === widget); const isVoiceSessionActive = derived(this, reader => { - if (!isVoiceInputActive.read(reader)) { - return false; - } const target = this.voiceSessionController.targetSession.read(reader); const hasDraftTarget = this.voiceSessionController.hasDraftTarget.read(reader); - const resource = widget.viewModel?.sessionResource; - return !hasDraftTarget && (!target || (!!resource && isEqual(target, resource))); + const resource = voiceSessionResource.read(reader); + return isVoiceSessionActiveForInput(isVoiceInputActive.read(reader), target, hasDraftTarget, resource); }); const inputPickerCompactStates = new Map>(); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index db1664ba3e2dbe..21feb2795966c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -9,14 +9,14 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, derived, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService as BaseTestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -34,7 +34,7 @@ import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessi import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; -import { VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; +import { isVoiceSessionActiveForInput, VoiceNewSessionPreparationResult, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatModelReference, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; @@ -53,6 +53,45 @@ class TestConfigurationService extends BaseTestConfigurationService { } } +suite('Voice Mode input ownership', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const target = URI.parse('agent-host-copilot:/session-1'); + const other = URI.parse('agent-host-copilot:/session-2'); + + test('follows a pinned session target independently of input focus', () => { + assert.deepStrictEqual({ + matchingUnfocused: isVoiceSessionActiveForInput(false, target, false, target), + mismatchedFocused: isVoiceSessionActiveForInput(true, target, false, other), + targetlessFocused: isVoiceSessionActiveForInput(true, undefined, false, target), + targetlessUnfocused: isVoiceSessionActiveForInput(false, undefined, false, target), + draft: isVoiceSessionActiveForInput(true, undefined, true, target), + }, { + matchingUnfocused: true, + mismatchedFocused: false, + targetlessFocused: true, + targetlessUnfocused: false, + draft: false, + }); + }); + + test('updates when a chat input materializes its pinned session resource', () => { + const viewModelChange = store.add(new Emitter()); + const viewModel: { resource: URI | undefined } = { resource: undefined }; + const sessionResource = observableFromEvent(store, viewModelChange.event, () => viewModel.resource); + const active = derived(store, reader => isVoiceSessionActiveForInput(false, target, false, sessionResource.read(reader))); + let current = false; + store.add(autorun(reader => { + current = active.read(reader); + })); + + viewModel.resource = target; + viewModelChange.fire(); + + assert.strictEqual(current, true); + }); +}); + class TestVoiceClientService extends mock() { private narrationCounter = 0; readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string; checkpoint?: IVoiceCheckpointNarrationMetadata; confirmationType?: VoiceConfirmationType }[] = []; @@ -663,6 +702,31 @@ class AdoptingCommandService extends TestCommandService { } } +class PreparingNewSessionCommandService extends TestCommandService { + constructor(private readonly result: VoiceNewSessionPreparationResult = 'sent') { + super(); + } + + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + if (commandId === '_chat.voice.prepareNewSession') { + return this.result as T; + } + if (commandId === '_chat.voice.getCurrentSession') { + return 'sessions-voice://new-chat/composer' as T; + } + return super.executeCommand(commandId, ...args); + } +} + +class RejectingNewSessionCommandService extends TestCommandService { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + if (commandId === '_chat.voice.prepareNewSession') { + throw new Error('preparation failed'); + } + return super.executeCommand(commandId, ...args); + } +} + class RejectingAcceptCommandService extends TestCommandService { override async executeCommand(commandId: string, ...args: unknown[]): Promise { if (commandId === '_chat.voice.acceptInput') { @@ -5381,6 +5445,93 @@ suite('VoiceSessionController', () => { }); }); + test('send_to_chat with new_session lets the host prepare its own session', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new PreparingNewSessionCommandService(); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-send', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-send', result: 'ok' }], + }); + }); + + test('send_to_chat with new_session does not fall back when host preparation fails', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new PreparingNewSessionCommandService('failed'); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-failed', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-failed', result: 'error' }], + }); + }); + + test('send_to_chat with new_session does not fall back when the host command rejects', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new RejectingNewSessionCommandService(); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-rejected', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-rejected', result: 'error' }], + }); + }); + test('send_to_chat with new_session and no text creates and targets a session without sending', async () => { const voiceClientService = new TestVoiceClientService(); const commandService = new TestCommandService(); @@ -5411,7 +5562,7 @@ suite('VoiceSessionController', () => { sent: [], acceptedInputs: [], target: 'chat-session://new/1', - toolResults: [{ callId: 'new-session-empty', result: 'error' }], + toolResults: [{ callId: 'new-session-empty', result: 'ok' }], awaitingReply: false, voiceState: 'idle', status: 'Hold to speak...', From 055e2e1bdd6ed86271d48f178ee339f8ffb25bf7 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:45:00 +0200 Subject: [PATCH 06/44] agentHost: ignore host notice turns when resolving the last turn (#334301) The Agents Window sometimes showed an empty changeset for the last turn. Agent Merge posts status notices ("Agent Merge is enabled for `x`") as complete, hidden turns via `_writeAgentMergeNotice`. These never reach the provider and never capture a checkpoint, but both last-turn resolvers took `turns.at(-1)`, so the notice became "the last turn" and its empty changeset replaced the real one. Because the notice's request row is hidden, the transcript still ended on the user's turn, so the UI looked correct. The same notice also displaced the session changeset's git fast path: `_latestTurnIdAcrossChats` found no checkpoint for it and fell back to the edit tracker, which cannot see terminal-tool edits. Add `isHostNoticeTurn` / `lastAttributableTurnId` and use them in both resolvers. Visible system notifications (background-agent completions, Agent Merge repair prompts) are real turns and are deliberately not matched. Also make empty per-turn changesets diagnosable: every path in `_computeSingleFolderTurnDiffs` that can return an empty list now logs why, and `showBlob` logs the real git failure instead of discarding it, so a timeout or pruned ref no longer surfaces only as "git blob not found". Its budget goes to 15s, since a timeout there drops a diff's original side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionState.ts | 28 +++++++ .../node/agentHostChangesetService.ts | 22 +++-- .../agentHost/node/agentHostGitService.ts | 15 +++- .../node/agentHostChangesetService.test.ts | 62 +++++++++++++- .../browser/agentHostSessionChangesets.ts | 7 +- .../localAgentHostSessionsProvider.test.ts | 83 +++++++++++++++++++ 6 files changed, 205 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 1b27fd2b11236e..ea20300c048aca 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -18,6 +18,7 @@ import type { IProductService } from '../../../product/common/productService.js' import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; import { + MessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, @@ -331,6 +332,33 @@ export function withMessageRequestHiddenFromTranscript(message: Message, hidden: }; } +/** + * Whether `turn` is a hidden system notification the host appended purely to + * carry a message (e.g. an Agent Merge status change). It never reaches the + * provider and never captures a checkpoint, so it can never own file changes + * and must be skipped when resolving a "last turn" for per-turn changes. + * + * A *visible* system notification (a background-agent completion, an Agent + * Merge repair prompt) is a real turn and is deliberately not matched. + */ +export function isHostNoticeTurn(turn: { readonly message: Message }): boolean { + return turn.message.origin.kind === MessageKind.SystemNotification + && (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message)); +} + +/** Returns the last turn id that can own file changes, or `undefined` if there is none. */ +export function lastAttributableTurnId(turns: readonly { readonly id: string; readonly message: Message }[] | undefined): string | undefined { + if (!turns) { + return undefined; + } + for (let i = turns.length - 1; i >= 0; i--) { + if (!isHostNoticeTurn(turns[i])) { + return turns[i].id; + } + } + return undefined; +} + /** Whole-turn token consumption attributed to a single model. */ export interface ITurnTokenTotal { readonly model: string; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 139913434eb29f..266bc0cfca037d 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -30,6 +30,7 @@ import { type URI as ProtocolURI, readSessionGitState, isDefaultChatUri, + lastAttributableTurnId, SessionLifecycle, } from '../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -705,6 +706,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC /** * The single-folder per-turn diff: prefer the checkpoint-ref git diff of the * primary working directory, else fall back to the SDK-tracked aggregator. + * + * Every path that can return an empty list is logged — an empty per-turn + * changeset is otherwise indistinguishable from a turn that changed nothing. */ private async _computeSingleFolderTurnDiffs(session: ProtocolURI, trackedSession: ProtocolURI, db: ISessionDatabase, turnId: string): Promise { const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId); @@ -719,12 +723,19 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC if (fromRefDiffs) { return fromRefDiffs; } + this._logService.warn(`[AgentHostChangesetService] Turn ${session}/${turnId}: git diff ${pair.parent}..${pair.current} produced no result; falling back to tracked file edits, which cannot see terminal-tool edits`); + } else { + this._logService.warn(`[AgentHostChangesetService] Turn ${session}/${turnId}: no working directory resolved; falling back to tracked file edits, which cannot see terminal-tool edits`); } - } else if (pair && pair.parent === pair.current) { + } else if (pair) { // A no-op turn checkpoint reuses the parent ref (so per-turn // diff is empty by construction) — short-circuit to an empty // list instead of asking git for the (empty) diff. + this._logService.debug(`[AgentHostChangesetService] Turn ${session}/${turnId}: end-of-turn tree matches the turn-start tree (${pair.current}); reporting no changes`); return []; + } else { + // Expected for a non-git folder; otherwise checkpoint capture failed. + this._logService.debug(`[AgentHostChangesetService] Turn ${session}/${turnId}: no checkpoint pair; falling back to tracked file edits, which cannot see terminal-tool edits`); } // Fallback: SDK-tracked file_edits aggregator. return computeTurnDiffs(trackedSession, db, this._diffComputeService, turnId); @@ -1469,8 +1480,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * the session's shared working tree. For single-chat sessions this is the * default chat's last turn. For multi-chat sessions it is the last turn of * the most-recently-modified chat (peer-chat turn checkpoints are stored - * under the session URI keyed by their turn id). Returns `undefined` when - * no chat has any turns. + * under the session URI keyed by their turn id). Host notice turns are + * skipped — they capture no checkpoint, so picking one would drop the + * session changeset off its git fast path. */ private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined { const sessionState = this._stateManager.getSessionState(session); @@ -1480,7 +1492,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const chats = sessionState.chats ?? []; if (chats.length <= 1) { - return sessionState.turns.at(-1)?.id; + return lastAttributableTurnId(sessionState.turns); } let bestTurnId: string | undefined; @@ -1489,7 +1501,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const turns = isDefaultChatUri(chat.resource) ? sessionState.turns : this._stateManager.getChatState(chat.resource)?.turns; - const lastTurnId = turns?.at(-1)?.id; + const lastTurnId = lastAttributableTurnId(turns); if (lastTurnId && chat.modifiedAt >= bestModifiedAt) { bestModifiedAt = chat.modifiedAt; bestTurnId = lastTurnId; diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 126bb5a0f8c776..d143d376235390 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -32,6 +32,9 @@ const WORKTREE_REMOVAL_MAX_ATTEMPTS = 5; const WORKTREE_REMOVAL_RETRY_BASE_DELAY_MS = 100; const WORKTREE_REMOVAL_RETRY_MAX_DELAY_MS = 500; +/** Budget for reading one blob; a timeout here drops a diff's original side. */ +const SHOW_BLOB_TIMEOUT_MS = 15_000; + export class AgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -707,12 +710,16 @@ export class AgentHostGitService implements IAgentHostGitService { return undefined; } - // `git show` exits non-zero when the path didn't exist at that - // ref; `_runGit` swallows that into `undefined` which is exactly - // the contract callers want. + const args = ['show', `${ref}:${repoRelativePath}`]; + this._logService.trace(`[agentHostGitService] > git ${args.join(' ')}`); + + // Callers only get `undefined`, which surfaces as "git blob not found" + // whatever actually went wrong, so log the real reason. return new Promise((resolve) => { - cp.execFile('git', ['show', `${ref}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { + cp.execFile('git', args, { cwd: workingDirectory.fsPath, timeout: SHOW_BLOB_TIMEOUT_MS, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { + // The timeout above is the only thing that kills this process. + this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, SHOW_BLOB_TIMEOUT_MS, error.killed === true, error, (stderr as Buffer).toString())}`); resolve(undefined); return; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index d36874140a29e9..5c0ab8761915d4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -16,7 +16,7 @@ import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { META_CHANGES_SUMMARY } from '../../common/agentHostChangesetService.js'; @@ -1430,6 +1430,17 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }; } + /** Polls until `changesetUri` reaches `Ready`. */ + async function waitForChangesetReady(stateManager: AgentHostStateManager, changesetUri: string): Promise { + for (let i = 0; i < 500; i++) { + if (stateManager.getChangesetState(changesetUri)?.status === ChangesetStatus.Ready) { + return; + } + await timeout(1); + } + assert.fail(`changeset ${changesetUri} never reached Ready`); + } + function build(options: { workingDirectories: string[]; git: IAgentHostGitService; @@ -1814,6 +1825,55 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { assert.strictEqual(repoRootCalls, 0, 'single-folder fallback must not resolve repositories'); }); + /** + * A host notice turn captures no checkpoint, so picking it as the session's + * latest turn drops the session changeset onto the edit tracker, which + * cannot see terminal-tool edits. + */ + test('session changeset keeps its git fast path when the chat ends on a host notice turn', async () => { + const git = createNoopGitService(); + git.getRepositoryRoot = async wd => URI.parse(wd.toString()); + const diffCalls: Array<{ fromRef: string; toRef: string }> = []; + git.computeFileDiffsBetweenRefs = async (_wd, opts) => { + diffCalls.push({ fromRef: opts.fromRef, toRef: opts.toRef }); + return [gitDiff('/wd/edited.ts', 3, 1)]; + }; + const checkpoint: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + getBaselineCheckpoint: async () => 'baseline', + // Mirrors production: only the agent's turn has a checkpoint. + getTurnCheckpointPair: async (_session: URI, turnId: string) => + turnId === 'agent-turn' ? { parent: 'agent~p', current: 'agent~c' } : undefined, + }; + const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], git, checkpoint }); + + const chat = buildDefaultChatUri(sessionStr); + for (const turn of [ + { id: 'agent-turn', message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } } }, + { + id: 'notice-turn', + message: withMessageRequestHiddenFromTranscript( + { text: 'Agent Merge is enabled for `feature`.', origin: { kind: MessageKind.SystemNotification } }, + true, + ), + }, + ]) { + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: turn.id, startedAt: new Date(0).toISOString(), message: turn.message }); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: turn.id, duration: 1 }); + } + + svc.refreshSessionChangeset(sessionStr); + await waitForChangesetReady(stateManager, buildSessionChangesetUri(sessionStr)); + + assert.deepStrictEqual({ + diffCalls, + files: stateManager.getChangesetState(buildSessionChangesetUri(sessionStr))?.files.map(file => file.id), + }, { + diffCalls: [{ fromRef: 'baseline', toRef: 'agent~c' }], + files: [URI.file('/wd/edited.ts').toString()], + }); + }); + /** * All-folder branch summary (AC-3). In a multi-folder session the * `summary.changes` chip must reflect EVERY folder's branch delta, computed diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 35a78f96715ced..a6682134ae66c6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -18,7 +18,7 @@ import { isAgentMergeMessage } from '../../../../../platform/agentHost/common/me import { ChangesetOperationTargetKind } from '../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, ChangesetOperationStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildDefaultChatUri, ChangesetStatus, Changeset, MessageKind, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildDefaultChatUri, ChangesetStatus, Changeset, isHostNoticeTurn, lastAttributableTurnId, MessageKind, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ISessionChangeset, ISessionChangesetCapabilities, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; import { isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -566,7 +566,10 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { // Prefer the in-progress turn so the "last turn" reflects streaming // edits live; once it completes it moves into `turns` under the same // id, so the tracked changeset transitions seamlessly. - return chatState.activeTurn?.id ?? chatState.turns?.at(-1)?.id; + if (chatState.activeTurn && !isHostNoticeTurn(chatState.activeTurn)) { + return chatState.activeTurn.id; + } + return lastAttributableTurnId(chatState.turns); }); // Last turn changes diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 56ec50ca917ca5..cb033f3541df18 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5942,6 +5942,89 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('Last Turn Changes ignores a trailing host notice turn', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const workingDirectory = URI.file('/repo'); + agentHost.addSession(createSession('notice-turn-changes', { summary: 'Notice Turn Changes', workingDirectory })); + const activeSession = observableValue('activeSession', undefined); + const provider = createProvider(disposables, agentHost, undefined, { activeSession }); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions().find(candidate => candidate.title.get() === 'Notice Turn Changes'); + assert.ok(session); + activeSession.set(session as IActiveSession, undefined); + assert.ok(session instanceof AgentHostSessionAdapter); + + const sessionUri = AgentSession.uri('copilotcli', 'notice-turn-changes').toString(); + const chatUri = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('notice-turn-changes', 'copilotcli', { + provider: 'copilotcli', + title: 'Notice Turn Changes', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat: chatUri, + chats: [{ + resource: chatUri, + title: 'Default', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date(0).toISOString(), + }], + workingDirectories: [workingDirectory.toString()], + }); + session.updateChangesets([{ + label: 'Last Turn Changes', + uriTemplate: `${sessionUri}/changeset/turn/{turnId}`, + changeKind: 'turn', + }]); + + const changedFile = URI.file('/repo/edited.ts'); + agentHost.setChangesetState(`${sessionUri}/changeset/turn/agent-turn`, { + status: ChangesetStatus.Ready, + files: [{ + id: changedFile.toString(), + edit: { + after: { uri: changedFile.toString(), content: { uri: changedFile.toString() } }, + diff: { added: 3, removed: 1 }, + }, + }], + }); + agentHost.setChangesetState(`${sessionUri}/changeset/turn/notice-turn`, { status: ChangesetStatus.Ready, files: [] }); + + // The agent's turn, followed by a hidden Agent Merge notice turn. + agentHost.setChatState(chatUri, { + resource: chatUri, + title: 'Default', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date().toISOString(), + turns: [{ + id: 'agent-turn', + message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }, { + id: 'notice-turn', + message: { + text: '\nAgent Merge is enabled for `feature`.', + origin: { kind: MessageKind.SystemNotification }, + }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }], + }); + + const changeset = session.changesets.get()?.find(candidate => candidate.id === TURN_CHANGES_CHANGESET_ID); + assert.deepStrictEqual({ + isEnabled: changeset?.isEnabled.get(), + changes: changeset?.changes.get().map(change => isIChatSessionFileChange2(change) ? change.uri.toString() : change.modifiedUri.toString()), + }, { + isEnabled: true, + changes: [changedFile.toString()], + }); + })); + test('registers provider-neutral resource label homes for quick chats and provider state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const claudeHome = URI.file('/home/test/.agent/chats/claude-session'); const rootHome = URI.file('/'); From d18a096ebe67e963074cf0744bf9afb0ec4879e8 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 3 Sep 2026 18:10:43 +0100 Subject: [PATCH 07/44] Chat: preserve compact Agent Merge icon sizing (#334313) fix: refine CSS selectors for chat agent merge components Co-authored-by: mrleemurray --- .../chatContentParts/media/chatAgentMergeContent.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css index 46888842418dbd..61aab7e3bc9e68 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css @@ -108,7 +108,7 @@ display: flex; } -.chat-agent-merge .chat-agent-merge-message-toggle.codicon { +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-message-toggle.codicon { font-size: var(--vscode-codiconFontSize-compact); } @@ -126,9 +126,8 @@ background-color: var(--vscode-toolbar-activeBackground); } -/* `.codicon` joins each compound so these sizes outrank the `font` shorthand on -the codicon base rule, which would otherwise force every glyph to 16px. */ -.chat-agent-merge .chat-agent-merge-twistie.codicon { +/* The card scope keeps compact sizing ahead of the workbench codicon rule. */ +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-twistie.codicon { position: relative; z-index: 1; flex: 0 0 auto; @@ -222,7 +221,7 @@ which sits either on an ancestor or on the workbench element itself. */ } /* Shared leading icon: a fixed width keeps comment bodies and check names on one indent. */ -.chat-agent-merge .chat-agent-merge-row-icon.codicon { +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-row-icon.codicon { flex: 0 0 auto; width: var(--vscode-codiconFontSize-compact); font-size: var(--vscode-codiconFontSize-compact); From 7949faffc31e1f0cf85f5d78b9ef035c1ed2880f Mon Sep 17 00:00:00 2001 From: roblourens Date: Thu, 3 Sep 2026 10:12:06 -0700 Subject: [PATCH 08/44] agentHost: allow turns with unavailable subagent transcripts (#334312) Do not reject a parent turn when restored subagent history is temporarily unavailable during duplicate turn-id validation. Keep the child resolver retryable and preserve strict validation for other peer-chat failures.\n\nAdd unit and whole-host E2E coverage for the recovery path, plus a gated test for the Copilot runtime custom-agent displayName contract mismatch.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 22 ++- .../agentHost/test/node/agentService.test.ts | 34 +++- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 17 ++ ...r-a-custom-subagent-has-no-transcript.yaml | 77 +++++++++ .../test/node/e2e/suites/subagentSuite.ts | 155 +++++++++++++++++- 5 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9089c59bf1ff1d..9f571b26446724 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -347,6 +347,13 @@ class ProviderCatalogUnavailableError extends Error { } } +class SubagentTranscriptUnavailableError extends Error { + constructor(chat: string) { + super(`Subagent transcript is not available yet: ${chat}`); + this.name = 'SubagentTranscriptUnavailableError'; + } +} + /** * Reconcile a session's working-directory set from a create-result / * materialization receipt. The resolved receipt is authoritative for the roots @@ -4856,12 +4863,21 @@ export class AgentService extends Disposable implements IAgentService { } private async _resolvePeerChatsForTurnValidation(sessionChannel: string): Promise { + const unavailableSubagentTranscripts = new Set(); while (true) { - const unresolvedChats = this._getUnresolvedPeerChats(sessionChannel); + const unresolvedChats = this._getUnresolvedPeerChats(sessionChannel)?.filter(chat => !unavailableSubagentTranscripts.has(chat)); if (!unresolvedChats) { throw new Error('Cannot validate turn id for unknown session'); } if (unresolvedChats.length === 0) { return; } await Promise.all(unresolvedChats.map(async chat => { - if (!await this._stateManager.resolveChatState(chat)) { throw new Error('Cannot resolve peer chat for turn id validation'); } + try { + if (!await this._stateManager.resolveChatState(chat)) { throw new Error('Cannot resolve peer chat for turn id validation'); } + } catch (error) { + if (!(error instanceof SubagentTranscriptUnavailableError)) { + throw error; + } + unavailableSubagentTranscripts.add(chat); + this._logService.warn(`[AgentService] Cannot validate turn ids against unavailable subagent transcript: ${chat}`); + } })); } } @@ -7183,7 +7199,7 @@ export class AgentService extends Disposable implements IAgentService { private async _resolveRestoredSubagentTurns(agent: IAgent, parentSession: URI, chatUri: string, origin: { readonly kind: ChatOriginKind.Tool; readonly chat: string; readonly toolCallId: string }): Promise { const childTurns = await this._getChatMessages(agent, URI.parse(chatUri), parentSession, origin); if (childTurns.length === 0) { - throw new Error(`Subagent transcript is not available yet: ${chatUri}`); + throw new SubagentTranscriptUnavailableError(chatUri); } return this._interleaveLocalTurns(parentSession.toString(), chatUri, childTurns); } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9a87891adb2b81..ff886d015d829d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9252,11 +9252,10 @@ suite('AgentService (node dispatcher)', () => { test('registers subagent summaries without loading child transcripts until subscription', async () => { class LazySubagentMockAgent extends MockAgent { readonly messageReads: string[] = []; - private returnEmptyChildOnce = true; + childTranscriptAvailable = false; override async getSessionMessages(session: URI): Promise { this.messageReads.push(session.toString()); - if (parseChatUri(session)?.chatId.startsWith('subagent/') && this.returnEmptyChildOnce) { - this.returnEmptyChildOnce = false; + if (parseChatUri(session)?.chatId.startsWith('subagent/') && !this.childTranscriptAvailable) { return []; } return super.getSessionMessages(session); @@ -9304,12 +9303,33 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(service.subscribe(URI.parse(childChatUri), 'child-reader-first'), /Subagent transcript is not available yet/); assert.strictEqual(getStateManager(service).getChatState(childChatUri), undefined); + const envelopePromise = Event.toPromise(Event.filter(service.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + const sendPromise = Event.toPromise(agent.onDidSendMessage); + service.dispatchAction(buildDefaultChatUri(sessionResource), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-after-missing-subagent', + startedAt: '2026-09-02T16:15:03.293Z', + message: { text: 'Start it for me', origin: { kind: MessageKind.User } }, + }, 'client-test', 1); + const [envelope, send] = await Promise.all([envelopePromise, sendPromise]); + agent.childTranscriptAvailable = true; await service.subscribe(URI.parse(childChatUri), 'child-reader-second'); const childState = getStateManager(service).getChatState(childChatUri); - assert.ok(childState); - assert.strictEqual(childState.turns.length, 1); - assert.strictEqual(agent.messageReads.filter(resource => resource === childChatUri).length, 2); - assert.strictEqual(getStateManager(service).getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), undefined); + assert.deepStrictEqual({ + turnRejected: envelope.rejectionReason !== undefined, + parentActiveTurn: getStateManager(service).getChatState(buildDefaultChatUri(sessionResource))?.activeTurn?.id, + sentPrompt: send.prompt, + childTurnCount: childState?.turns.length, + childMessageReads: agent.messageReads.filter(resource => resource === childChatUri).length, + legacyChildSession: getStateManager(service).getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), + }, { + turnRejected: false, + parentActiveTurn: 'turn-after-missing-subagent', + sentPrompt: 'Start it for me', + childTurnCount: 1, + childMessageReads: 3, + legacyChildSession: undefined, + }); }); test('legacy subagent reconstruction replaces only a generic restored title', async () => { diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 06b1c942cd0a2a..e5cd4e99dcc043 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -807,6 +807,23 @@ Use the affected provider command with `--grep ""` and tempora - Related investigation: [#325284](https://github.com/microsoft/vscode/pull/325284). - Reproduce: temporarily clear the gate and run the exact title with `scripts\test-integration.bat`. +### Copilot custom subagent without a display name + +A client-contributed custom agent can specify its stable name, description, and prompt without a separate display name. Invoking that agent as a child should run its prompt and return its response to the parent. Instead, the bundled Copilot runtime starts the child and immediately fails it with `failed to assemble custom-agent system prompt: displayName: Required`. The same validation boundary also rejects the SDK's documented `null`/omitted all-tools representation with `tools: Expected array`, so custom agents that follow either optional-field contract cannot run as subagents. + +- Test: `custom agent without a display name completes as a subagent`. +- Scope: Copilot. +- Expected: the child responds with `CUSTOM_AGENT_CHILD_OK` and completes. +- Observed: `subagent.started` is followed by `subagent.failed` before the child makes a model request. +- Gate: live recording with `AGENT_HOST_RUN_KNOWN_ISSUES=1` until the runtime fix is included in the bundled SDK. +- Reproduce: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "custom agent without a display name completes as a subagent" + ``` + ### Mid-turn abort is record-only - Tests: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml new file mode 100644 index 00000000000000..96034db95c2b82 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml @@ -0,0 +1,77 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + - role: assistant + content: + - type: tool_use + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + failed to assemble custom-agent system prompt: displayName: Required + tools: Expected array + response: + content: SETUP_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + - role: assistant + content: + - type: tool_use + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + failed to assemble custom-agent system prompt: displayName: Required + tools: Expected array + - role: assistant + content: SETUP_DONE + - role: user + content: Reply exactly "PARENT_RECOVERED". + response: + content: PARENT_RECOVERED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts index 98ae91a8690bd5..65685e4e0fe09e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mkdtempSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { retry } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { AgentHostConfigKey } from '../../../../common/agentHostCustomizationConfig.js'; import { SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType, type ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; import { ResponsePartKind, + ROOT_STATE_URI, ToolCallConfirmationReason, ToolResultContentType, buildDefaultChatUri, @@ -22,12 +25,154 @@ import { type ToolResultContent, type ToolResultSubagentContent, } from '../../../../common/state/sessionState.js'; -import { createRealSession, dispatchTurn } from '../harness/agentHostE2ETestHarness.js'; +import { createRealSession, dispatchTurn, driveTurnToCompletion, getMarkdownResponseText } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs, isWindows } = context; + + function createCustomAgentWorkspace(prefix: string): string { + const workspace = mkdtempSync(join(tmpdir(), prefix)); + const agentsDirectory = join(workspace, '.github', 'agents'); + mkdirSync(agentsDirectory, { recursive: true }); + writeFileSync(join(agentsDirectory, 'display-name-child.agent.md'), [ + '---', + 'name: e2e-display-name-child', + 'description: Returns the custom child sentinel', + 'tools:', + ' - view', + '---', + 'Reply exactly "CUSTOM_AGENT_CHILD_OK". Do not call tools.', + ].join('\n')); + tempDirs.push(workspace); + return workspace; + } + + async function createCustomAgentSession(prefix: string): Promise { + const workspace = createCustomAgentWorkspace(prefix); + const sessionUri = await createRealSession(context.client, config, prefix, createdSessions, URI.file(workspace)); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: 1, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostConfigKey.SessionCustomizationDiscoveryMode]: 'scan' }, + }, + }); + return sessionUri; + } + + function subagentChatFromReceived(parentChat: string): string | undefined { + for (const notification of context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallContentChanged'))) { + const envelope = getActionEnvelope(notification); + if (envelope.channel !== parentChat) { + continue; + } + const content = (envelope.action as { content: readonly ToolResultContent[] }).content; + const subagent = content.find((item): item is ToolResultSubagentContent => item.type === ToolResultContentType.Subagent); + if (subagent) { + return subagent.resource; + } + } + return undefined; + } + + function markdownText(state: ChatState | undefined): string { + return state?.turns.flatMap(turn => turn.responseParts) + .filter(part => part.kind === ResponsePartKind.Markdown) + .map(part => part.content) + .join('') ?? ''; + } + + function responsePartIds(turns: ISessionWithDefaultChat['turns']): string[] { + return turns.flatMap(turn => turn.responseParts.flatMap(part => { + const id = Reflect.get(part, 'id'); + return typeof id === 'string' ? [id] : []; + })); + } + + const copilotCustomAgentTest = config.provider === 'copilotcli' && config.supportsSubagents; + + // The bundled runtime currently rejects the SDK's optional displayName field; keep this executable in known-issue recording until that runtime fix ships. + (context.runKnownIssueTests && copilotCustomAgentTest ? test : test.skip)('custom agent without a display name completes as a subagent', async function () { + this.timeout(180_000); + + const sessionUri = await createCustomAgentSession('ahp-custom-agent-display-name-'); + const parentChat = buildDefaultChatUri(sessionUri); + await driveTurnToCompletion( + context.client, + sessionUri, + 'turn-custom-agent-display-name', + 'Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "PARENT_DONE".', + 2, + ); + + const subagentChat = subagentChatFromReceived(parentChat); + assert.ok(subagentChat, 'the parent tool call should expose the custom subagent chat'); + const snapshot = await context.client.call('subscribe', { channel: subagentChat }); + assert.match(markdownText(snapshot.snapshot?.state as ChatState | undefined), /CUSTOM_AGENT_CHILD_OK/); + }); + + (copilotCustomAgentTest ? test : test.skip)('restored parent accepts a new turn after a custom subagent has no transcript', async function () { + this.timeout(240_000); + + const sessionUri = await createCustomAgentSession('ahp-missing-custom-agent-transcript-'); + const parentChat = buildDefaultChatUri(sessionUri); + const setup = await driveTurnToCompletion( + context.client, + sessionUri, + 'turn-create-missing-subagent-transcript', + 'Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE".', + 2, + ); + assert.match(setup.responseText, /SETUP_DONE/); + assert.ok(subagentChatFromReceived(parentChat), 'the failed custom subagent should remain in the parent chat catalog'); + + const liveParent = await fetchSessionWithChat(context.client, sessionUri); + const liveResponsePartIds = responsePartIds(liveParent.turns); + assert.ok(liveResponsePartIds.length > 0); + + const unsubscribeParent = () => { + context.client.notify('unsubscribe', { channel: parentChat }); + context.client.notify('unsubscribe', { channel: sessionUri }); + }; + unsubscribeParent(); + + await retry(async () => { + const restored = await fetchSessionWithChat(context.client, sessionUri); + const restoredResponsePartIds = responsePartIds(restored.turns); + if (restoredResponsePartIds.length === liveResponsePartIds.length + && restoredResponsePartIds.every((id, index) => id === liveResponsePartIds[index])) { + unsubscribeParent(); + throw new Error('parent session has not been reconstructed from persisted provider state'); + } + }, 50, 100); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-after-missing-subagent-transcript', 'Reply exactly "PARENT_RECOVERED".', 3); + const started = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/turnStarted')) { + return false; + } + const envelope = getActionEnvelope(n); + return envelope.channel === parentChat + && envelope.action.type === ActionType.ChatTurnStarted + && envelope.action.turnId === 'turn-after-missing-subagent-transcript'; + }, 30_000); + assert.strictEqual(getActionEnvelope(started).rejectionReason, undefined); + await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/turnComplete')) { + return false; + } + const envelope = getActionEnvelope(n); + return envelope.channel === parentChat + && envelope.action.type === ActionType.ChatTurnComplete + && envelope.action.turnId === 'turn-after-missing-subagent-transcript'; + }, 90_000); + assert.match(getMarkdownResponseText(context.client), /PARENT_RECOVERED/); + }); + (config.supportsSubagents ? test : test.skip)('subagent tool calls are routed to the subagent session, not flat in the parent', async function () { this.timeout(180_000); @@ -229,12 +374,6 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const assistantText = (turns: ISessionWithDefaultChat['turns']): string => turns.map(t => t.responseParts.map(p => p.kind === ResponsePartKind.Markdown ? p.content : '').join('')).join('\n'); - const responsePartIds = (turns: ISessionWithDefaultChat['turns']): string[] => - turns.flatMap(turn => turn.responseParts.flatMap(part => { - const id = Reflect.get(part, 'id'); - return typeof id === 'string' ? [id] : []; - })); - const liveParent = await fetchSessionWithChat(context.client, sessionUri); const liveParentResponsePartIds = responsePartIds(liveParent.turns); assert.ok(liveParentResponsePartIds.length > 0); From 7dc094845e433d55d7a2d171eae0b222859b75a2 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 18:31:04 +0100 Subject: [PATCH 09/44] workbench: fix compact activity menu in Modern UI Keep the fixed application menu viewport-positioned while centering the floating activity rail, and align its control geometry and states with neighboring activity targets. Add regression coverage for fixed overlays escaping the rail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/floatingPanels.css | 13 ++++-- .../modernUI/browser/media/activityBar.css | 33 +++++++++++++++ .../browser/modernUI.contribution.test.ts | 42 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index d1cd8916f9dd3f..9b6f080c7a55b7 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -307,18 +307,23 @@ /* * Center the full-size action targets in the card's lane (see `--modern-ui-activitybar-lane`). * The card's two 1px borders shrink its content box, so they come off the lane before it is - * halved — otherwise the items sit 1px off-center. + * halved — otherwise the items sit 1px off-center. Avoid a transform here so fixed menu + * descendants remain viewport-positioned instead of being contained and clipped by the rail. */ .monaco-workbench.floating-panels .part.activitybar > .content { - transform: translateX(calc((var(--modern-ui-activitybar-lane) - 2px) / 2)); + position: relative; + left: calc((var(--modern-ui-activitybar-lane) - 2px) / 2); } /* * Inset the first and last item from the card's ends by the same amount as the sides. The * horizontal inset is half the lane, of which the card's border already contributes one - * stroke, so the remainder is what these margins have to supply. Overrides the fixed values - * in `modernUI/browser/media/padding.css`, which predate the lane. + * stroke, so the remainder is what these margins have to supply. When the compact menu is + * present it forms the first cluster, with the composite bar retaining the same inter-cluster + * spacing. Overrides the fixed values in `modernUI/browser/media/padding.css`, which predate + * the lane. */ +.monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .menubar.compact, .monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .composite-bar { margin-top: calc(var(--modern-ui-activitybar-lane) / 2 - var(--vscode-strokeThickness)); } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css index 7d4b82f6149112..7827127cd28f07 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css @@ -55,6 +55,39 @@ height: calc(var(--activity-bar-action-height, 28px) - 4px); } +/* Keep the compact application menu on the same control tier and icon ramp as adjacent activity targets. */ +.modern-ui.monaco-workbench .activitybar .menubar.compact { + height: var(--activity-bar-action-height, 36px); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button { + justify-content: center; +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:focus { + background-color: transparent; +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact .toolbar-toggle-more { + box-sizing: border-box; + width: calc(var(--activity-bar-action-height, 36px) - var(--vscode-spacing-size40)); + height: calc(var(--activity-bar-action-height, 36px) - var(--vscode-spacing-size40)); + padding: 0; + border-radius: var(--vscode-cornerRadius-small); + font-size: var(--activity-bar-icon-size, var(--vscode-codiconFontSize)); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:focus:not(.open) .toolbar-toggle-more, +.modern-ui.monaco-workbench .activitybar .menubar.compact:not(:focus-within) > .menubar-menu-button:hover .toolbar-toggle-more { + color: var(--vscode-modernActivityBarItem-hoverForeground); + background-color: var(--vscode-modernActivityBarItem-hoverBackground); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button.open .toolbar-toggle-more { + color: var(--vscode-modernActivityBarItem-activeForeground); + background-color: var(--vscode-modernActivityBarItem-activeBackground); +} + :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; 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 8d9b72ea4a03ea..97d5cc40364f83 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 @@ -32,6 +32,7 @@ import { WorkbenchState } from '../../../../../platform/workspace/common/workspa import { ColorThemeData } from '../../../../services/themes/common/colorThemeData.js'; import { generateColorThemeCSS } from '../../../../services/themes/browser/colorThemeCss.js'; import '../../../../browser/media/floatingPanels.css'; +import '../../../../../base/browser/ui/menu/menubar.css'; import '../../../../browser/parts/activitybar/media/activityaction.css'; import '../../../../browser/parts/media/paneCompositePart.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; @@ -965,6 +966,47 @@ suite('ModernUIContribution', () => { }); }); + test('keeps floating rail overlays anchored to the viewport', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui floating-panels'; + root.style.display = 'inline-flex'; + root.style.setProperty('--activity-bar-width', '36px'); + root.style.setProperty('--vscode-spacing-sizeNone', '0px'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size60', '6px'); + root.style.setProperty('--vscode-spacing-size80', '8px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const activityBar = appendElement(root, 'part activitybar left'); + const content = appendElement(activityBar, 'content'); + const menubar = appendElement(content, 'menubar compact'); + const menuButton = appendElement(menubar, 'menubar-menu-button open'); + const menu = appendElement(menuButton, 'menubar-menu-items-holder monaco-menu-container'); + menu.style.top = '120px'; + menu.style.left = '240px'; + menu.style.width = '200px'; + menu.style.height = '160px'; + + const activityBarBounds = activityBar.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + + assert.deepStrictEqual({ + position: getWindow(menu).getComputedStyle(menu).position, + top: menuBounds.top, + left: menuBounds.left, + width: menuBounds.width, + leavesRail: menuBounds.left > activityBarBounds.right, + }, { + position: 'fixed', + top: 120, + left: 240, + width: 200, + leavesRail: true, + }); + }); + test('uses the editor surface border color', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui floating-panels'; From 55237077c23f8ebcbee93cc23fd4640d7c96fdb5 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 13:49:51 +0200 Subject: [PATCH 10/44] Preserve editor match rules when configuring defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c456dcf-e2d8-4cbe-9678-c0c0f25e3ccb --- .../browser/parts/editor/editorTypePicker.ts | 29 +- .../editor/browser/editorResolverService.ts | 301 ++++++++++++------ .../editor/common/editorResolverService.ts | 105 +++++- .../browser/editorResolverService.test.ts | 176 +++++++++- .../parts/editor/editorTypePicker.test.ts | 160 +++++++++- 5 files changed, 641 insertions(+), 130 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorTypePicker.ts b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts index 071604e8d7c414..ba5a156f5aa4ed 100644 --- a/src/vs/workbench/browser/parts/editor/editorTypePicker.ts +++ b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts @@ -4,13 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; -import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, SideBySideEditor, isDiffEditorInput, isEditorInputWithDiffResources } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorResolverService, RegisteredEditorInfo } from '../../../services/editor/common/editorResolverService.js'; +import { EditorMatches, IEditorResolverService, isUnconfiguredUniversalOptionalEditorMatch, RegisteredEditorInfo } from '../../../services/editor/common/editorResolverService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID } from './editorCommands.js'; @@ -24,13 +23,14 @@ export interface IAvailableEditorTypes { readonly originalResource?: URI; readonly modifiedResource?: URI; readonly currentId: string; + readonly editorMatches: EditorMatches; readonly editors: RegisteredEditorInfo[]; } /** * Determines the editors available for the given active editor's resource. Returns `undefined` when * there is nothing meaningful to switch between: no resource, only the default text editor, or an - * exclusive editor (e.g. the hex editor, for which `getEditors` returns an empty list). + * exclusive editor (e.g. the hex editor). */ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undefined, editorResolverService: IEditorResolverService, hiddenEditorIds?: readonly string[]): IAvailableEditorTypes | undefined { const standardDiffResources = isDiffEditorInput(activeEditor) ? { @@ -44,12 +44,14 @@ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undef } const currentId = activeEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id; const hiddenEditorIdSet = new Set(hiddenEditorIds); - const editors = editorResolverService.getEditors(resource, { - excludeUnconfiguredUniversalOptionalEditors: true, - currentEditorId: currentId, + const editorMatches = editorResolverService.getEditorMatches(resource, { isDiffEditor: !!diffResources, - }).filter(editor => editor.id === currentId || !hiddenEditorIdSet.has(editor.id)); - if (editors.length <= 1) { + }); + const editors = editorMatches.matches + .filter(match => !isUnconfiguredUniversalOptionalEditorMatch(match) || match.editor.id === currentId) + .map(match => match.editor) + .filter(editor => editor.id === currentId || !hiddenEditorIdSet.has(editor.id)); + if (editorMatches.hasExclusiveMatch || editors.length <= 1) { return undefined; } return { @@ -58,6 +60,7 @@ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undef originalResource: diffResources?.original, modifiedResource: diffResources?.modified, currentId, + editorMatches, editors }; } @@ -84,7 +87,8 @@ export function createEditorTypeActions( commandService: ICommandService, editorService: IEditorService ): IAction[] { - const glob = `*${extUri.extname(available.resource)}`; + const defaultRule = available.editorMatches.defaultRule; + const glob = defaultRule.associationPattern; // Show the contributing extension in parentheses, but only for extension-provided editors. // Built-in providers share this localized label, so their (redundant) source is omitted. @@ -106,15 +110,14 @@ export function createEditorTypeActions( // Persist the chosen editor as the default for this file type. For diffs this updates the // specialized `workbench.diffEditorAssociations` setting instead of the general one. The - // currently configured default (if any) is checked. Setting a default also reopens the active + // effective default is checked. Setting a default also reopens the active // editor with that type so the change takes effect immediately. - const configuredDefault = editorResolverService.getConfiguredDefaultEditor(available.resource, available.isDiffEditor); const setDefaultActions: IAction[] = available.editors.map(editor => toAction({ id: `setDefault.${editor.id}`, label: labelWithSource(editor), - checked: editor.id === configuredDefault, + checked: editor.id === defaultRule.editor.id, run: () => { - editorResolverService.updateUserAssociations(glob, editor.id, available.isDiffEditor); + editorResolverService.setDefaultEditor(available.resource, editor.id, available.isDiffEditor); return commandService.executeCommand(REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, editor.id); } })); diff --git a/src/vs/workbench/services/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index 6a6d6ff6ac84fc..0183d893b6097c 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -26,7 +26,7 @@ import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorIn import { IExtensionService } from '../../extensions/common/extensions.js'; import { findGroup } from '../common/editorGroupFinder.js'; import { IEditorGroup, IEditorGroupsService } from '../common/editorGroupsService.js'; -import { diffEditorsAssociationsSettingId, EditorAssociation, EditorAssociations, EditorInputFactoryObject, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorsOptions, priorityToRank, RegisteredEditorInfo, RegisteredEditorOptions, RegisteredEditorPriority, RegisteredEditorRegistrationInfo, ResolvedEditor, ResolvedStatus, toRegisteredEditorPriorityInfo } from '../common/editorResolverService.js'; +import { diffEditorsAssociationsSettingId, EditorAssociation, EditorAssociations, EditorInputFactoryObject, EditorMatchRule, EditorMatchRuleSource, editorsAssociationsSettingId, globMatchesResource, EditorMatches, IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorMatchesOptions, IEditorResolverServiceGetEditorsOptions, isUnconfiguredUniversalOptionalEditorMatch, priorityToRank, RegisteredEditorInfo, RegisteredEditorOptions, RegisteredEditorPriority, RegisteredEditorRegistrationInfo, ResolvedEditor, ResolvedStatus, toRegisteredEditorPriorityInfo } from '../common/editorResolverService.js'; import { PreferredGroup } from '../common/editorService.js'; interface RegisteredEditor { @@ -38,6 +38,21 @@ interface RegisteredEditor { type RegisteredEditors = Array; +const enum EditorSelectionSource { + Requested, + UserAssociation, + EditorPriority, + None +} + +type EditorSelection = + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.Requested; readonly conflictingDefault: false } + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.UserAssociation; readonly association: EditorAssociation; readonly conflictingDefault: false } + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.EditorPriority; readonly conflictingDefault: boolean } + | { readonly editor: undefined; readonly source: EditorSelectionSource.None; readonly conflictingDefault: false }; + +type DefaultEditorSelection = Exclude; + function normalizeRegisteredEditorInfo(editorInfo: RegisteredEditorRegistrationInfo): RegisteredEditorInfo { return { id: editorInfo.id, @@ -285,9 +300,106 @@ export class EditorResolverService extends Disposable implements IEditorResolver return this.getAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); } - getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined { - const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; - return this.getAssociationsForResourceFromSetting(resource, settingId)[0]?.viewType; + getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches { + this._flattenedEditors = this._flattenEditorsMap(); + + const associationType = options?.isDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const associations = this.getAssociationsForResourceByType(resource, associationType); + const editors = this.findMatchingEditors(resource, associationType, associations); + const selection = this.getDefaultEditorSelection(resource, associationType, false, editors, associations); + const naturalEditors = this.findMatchingEditors(resource, associationType, []); + const naturalSelection = this.getDefaultEditorSelection(resource, associationType, false, naturalEditors, []); + + const uniqueEditors = distinct(editors, editor => editor.editorInfo.id); + const defaultEditorId = selection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + const naturalDefaultEditorId = naturalSelection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + const matches = uniqueEditors.map(editor => this.createEditorMatchRule(resource, associationType, editor, associations, selection)); + if (!matches.some(match => match.editor.id === DEFAULT_EDITOR_ASSOCIATION.id) + && (defaultEditorId === DEFAULT_EDITOR_ASSOCIATION.id || naturalDefaultEditorId === DEFAULT_EDITOR_ASSOCIATION.id)) { + const defaultEditor = this._registeredEditors.find(editor => editor.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id); + const editorInfo = defaultEditor?.editorInfo ?? { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + detail: DEFAULT_EDITOR_ASSOCIATION.providerDisplayName, + priority: toRegisteredEditorPriorityInfo(RegisteredEditorPriority.builtin) + }; + matches.unshift(Object.freeze({ + editor: editorInfo, + priority: RegisteredEditorPriority.builtin, + source: EditorMatchRuleSource.Fallback, + associationPattern: this.getDefaultAssociationPattern(resource) + })); + } + + const defaultRuleIndex = matches.findIndex(match => match.editor.id === defaultEditorId); + const naturalDefaultRuleIndex = matches.findIndex(match => match.editor.id === naturalDefaultEditorId); + if (defaultRuleIndex === -1 || naturalDefaultRuleIndex === -1) { + throw new Error('The effective and natural default editors must be matching editors.'); + } + + return new EditorMatches(matches, defaultRuleIndex, naturalDefaultRuleIndex, selection.conflictingDefault); + } + + private createEditorMatchRule(resource: URI, associationType: EditorAssociationType, editor: RegisteredEditor, associations: EditorAssociations, selection: DefaultEditorSelection): EditorMatchRule { + const priority = this.getEffectivePriority(editor.editorInfo, associationType); + if (!selection.editor && editor.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.Fallback, + associationPattern: this.getDefaultAssociationPattern(resource) + }; + } + + if (selection.editor?.editorInfo.id === editor.editorInfo.id) { + if (selection.source === EditorSelectionSource.UserAssociation) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.UserAssociation, + association: selection.association, + associationPattern: selection.association.filenamePattern ?? this.getDefaultAssociationPattern(resource, selection.editor) + }; + } + if (selection.source === EditorSelectionSource.EditorPriority) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: selection.editor.globPattern, + associationPattern: this.getDefaultAssociationPattern(resource, selection.editor) + }; + } + } + + const association = associations.find(association => association.viewType === editor.editorInfo.id); + if (association && priority !== RegisteredEditorPriority.exclusive) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.UserAssociation, + association, + associationPattern: association.filenamePattern ?? this.getDefaultAssociationPattern(resource, editor) + }; + } + + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: editor.globPattern, + associationPattern: this.getDefaultAssociationPattern(resource, editor) + }; + } + + private getDefaultAssociationPattern(resource: URI, selectedEditor?: RegisteredEditor): string { + if (selectedEditor) { + return typeof selectedEditor.globPattern === 'string' && globMatchesResource(selectedEditor.globPattern, resource) + ? selectedEditor.globPattern + : `*${extname(resource)}`; + } + + return `*${extname(resource)}`; } private getAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { @@ -408,12 +520,22 @@ export class EditorResolverService extends Disposable implements IEditorResolver return Array.from(this._flattenedEditors.values()).flat(); } - updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void { - this.updateUserAssociationsForSetting(forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); - } + setDefaultEditor(resource: URI, editorID: string, forDiffEditor?: boolean): void { + const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; + const matches = this.getEditorMatches(resource, { isDiffEditor: forDiffEditor }); + const currentAssociation = this.getRawAssociationsForResourceFromSetting(resource, settingId)[0]; + if (editorID === matches.naturalDefaultRule.editor.id) { + const inheritedAssociation = forDiffEditor ? this.getRawAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId)[0] : undefined; + if (currentAssociation && (!inheritedAssociation || inheritedAssociation.viewType === editorID)) { + this.removeUserAssociationForSetting(settingId, currentAssociation.filenamePattern!); + return; + } + if (!currentAssociation && matches.defaultRule.editor.id === editorID) { + return; + } + } - private updateUserAssociationsForType(associationType: EditorAssociationType, globPattern: string, editorID: string): void { - this.updateUserAssociationsForSetting(associationType === EditorAssociationType.DiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); + this.updateUserAssociationsForSetting(settingId, currentAssociation?.filenamePattern ?? matches.defaultRule.associationPattern, editorID); } private updateUserAssociationsForSetting(settingId: string, globPattern: string, editorID: string): void { @@ -443,9 +565,8 @@ export class EditorResolverService extends Disposable implements IEditorResolver this.configurationService.updateValue(settingId, newSettingObject); } - private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor): RegisteredEditor[] { + private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor, userSettings = this.getAssociationsForResourceByType(resource, associationType)): RegisteredEditor[] { // The user setting should be respected even if the editor doesn't specify that resource in package.json - const userSettings = this.getAssociationsForResourceByType(resource, associationType); const matchingEditors: RegisteredEditor[] = []; // Then all glob patterns for (const [key, editors] of this._flattenedEditors) { @@ -483,24 +604,14 @@ export class EditorResolverService extends Disposable implements IEditorResolver // By resource if (URI.isUri(resourceOrOptions)) { - const resource = resourceOrOptions; - const associationType = options?.isDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; - let editors = this.findMatchingEditors(resource, associationType); - if (editors.find(editor => this.getEffectivePriority(editor.editorInfo, associationType) === RegisteredEditorPriority.exclusive)) { + const editorMatches = this.getEditorMatches(resourceOrOptions, options); + if (editorMatches.hasExclusiveMatch) { return []; } - if (options?.excludeUnconfiguredUniversalOptionalEditors) { - const configuredEditorIds = new Set(this.getAssociationsForResourceByType(resource, associationType).map(association => association.viewType)); - editors = editors.filter(editor => { - const priority = this.getEffectivePriority(editor.editorInfo, associationType); - return editor.globPattern !== '*' - || priority !== RegisteredEditorPriority.option - || editor.editorInfo.id === options.currentEditorId - || configuredEditorIds.has(editor.editorInfo.id); - }); - return distinct(editors.map(editor => editor.editorInfo), editor => editor.id); - } - return editors.map(editor => editor.editorInfo); + const matches = options?.excludeUnconfiguredUniversalOptionalEditors + ? editorMatches.matches.filter(match => !isUnconfiguredUniversalOptionalEditorMatch(match) || match.editor.id === options.currentEditorId) + : editorMatches.matches; + return matches.map(match => match.editor); } // All @@ -528,52 +639,44 @@ export class EditorResolverService extends Disposable implements IEditorResolver * Given a resource and an editorId selects the best possible editor * @returns The editor and whether there was another default which conflicted with it */ - private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined, associationType: EditorAssociationType): { editor: RegisteredEditor | undefined; conflictingDefault: boolean } { - - const findMatchingEditor = (editors: RegisteredEditors, viewType: string) => { - return editors.find((editor) => { - if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { - return false; - } - if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { - return false; - } - - if (editor.options?.canSupportResource !== undefined) { - return editor.editorInfo.id === viewType && editor.options.canSupportResource(resource); - } - return editor.editorInfo.id === viewType; - }); - }; - + private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined, associationType: EditorAssociationType): EditorSelection { if (editorId && editorId !== EditorResolution.EXCLUSIVE_ONLY) { // Specific id passed in doesn't have to match the resource, it can be anything - const registeredEditors = this._registeredEditors; - return { - editor: findMatchingEditor(registeredEditors, editorId), - conflictingDefault: false - }; + const editor = this.findEditor(resource, associationType, this._registeredEditors, editorId); + return editor + ? { editor, source: EditorSelectionSource.Requested, conflictingDefault: false } + : { editor: undefined, source: EditorSelectionSource.None, conflictingDefault: false }; } - const editors = this.findMatchingEditors(resource, associationType); + return this.getDefaultEditorSelection(resource, associationType, editorId === EditorResolution.EXCLUSIVE_ONLY); + } - const associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType); + private getDefaultEditorSelection(resource: URI, associationType: EditorAssociationType, exclusiveOnly = false, editors = this.findMatchingEditors(resource, associationType), associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType)): DefaultEditorSelection { // We only want minPriority+ if no user defined setting is found, else we won't resolve an editor - const minPriority = editorId === EditorResolution.EXCLUSIVE_ONLY ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; + const minPriority = exclusiveOnly ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; let possibleEditors = editors.filter(editor => priorityToRank(this.getEffectivePriority(editor.editorInfo, associationType)) >= priorityToRank(minPriority) && editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); if (possibleEditors.length === 0) { + const association = !exclusiveOnly ? associationsFromSetting[0] : undefined; + const editor = association ? this.findEditor(resource, associationType, editors, association.viewType) : undefined; + return editor && association + ? { editor, source: EditorSelectionSource.UserAssociation, association, conflictingDefault: false } + : { editor: undefined, source: EditorSelectionSource.None, conflictingDefault: false }; + } + // If the editor is exclusive we use that, else use the user setting, else we check canSupportResource, else take the viewtype of first possible editor + const configuredEditor = associationsFromSetting[0] ? this.findEditor(resource, associationType, editors, associationsFromSetting[0].viewType) : undefined; + const exclusiveEditor = this.getEffectivePriority(possibleEditors[0].editorInfo, associationType) === RegisteredEditorPriority.exclusive ? possibleEditors[0] : undefined; + if (configuredEditor && !exclusiveEditor) { return { - editor: associationsFromSetting[0] && minPriority !== RegisteredEditorPriority.exclusive ? findMatchingEditor(editors, associationsFromSetting[0].viewType) : undefined, + editor: configuredEditor, + source: EditorSelectionSource.UserAssociation, + association: associationsFromSetting[0], conflictingDefault: false }; } - // If the editor is exclusive we use that, else use the user setting, else we check canSupportResource, else take the viewtype of first possible editor - const configuredEditor = associationsFromSetting[0] ? findMatchingEditor(editors, associationsFromSetting[0].viewType) : undefined; - const selectedViewType = this.getEffectivePriority(possibleEditors[0].editorInfo, associationType) === RegisteredEditorPriority.exclusive ? - possibleEditors[0].editorInfo.id : - configuredEditor?.editorInfo.id || - (possibleEditors.find(editor => (!editor.options?.canSupportResource || editor.options.canSupportResource(resource)))?.editorInfo.id) || - possibleEditors[0].editorInfo.id; + + const selectedEditor = exclusiveEditor + ?? possibleEditors.find(editor => !editor.options?.canSupportResource || editor.options.canSupportResource(resource)) + ?? possibleEditors[0]; let conflictingDefault = false; @@ -587,11 +690,24 @@ export class EditorResolverService extends Disposable implements IEditorResolver } return { - editor: findMatchingEditor(editors, selectedViewType), + editor: selectedEditor, + source: EditorSelectionSource.EditorPriority, conflictingDefault }; } + private findEditor(resource: URI, associationType: EditorAssociationType, editors: RegisteredEditors, editorId: string): RegisteredEditor | undefined { + return editors.find(editor => { + if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { + return false; + } + if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { + return false; + } + return editor.editorInfo.id === editorId && (!editor.options?.canSupportResource || editor.options.canSupportResource(resource)); + }); + } + private getEffectivePriority(editorInfo: RegisteredEditorInfo, associationType: EditorAssociationType): RegisteredEditorPriority { switch (associationType) { case EditorAssociationType.DiffEditor: @@ -799,65 +915,61 @@ export class EditorResolverService extends Disposable implements IEditorResolver }); } - private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker: boolean | undefined, associationType: EditorAssociationType) { + private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker: boolean | undefined, associationType: EditorAssociationType, defaultAssociationType = associationType) { const currentEditor = this.editorGroupService.activeGroup.findEditors(resource).at(0); // If untitled, we want all registered editors - let registeredEditors = resource.scheme === Schemas.untitled ? this._registeredEditors.filter(e => e.editorInfo.priority.editor !== RegisteredEditorPriority.exclusive) : this.findMatchingEditors(resource, associationType); - if (associationType === EditorAssociationType.DiffEditor) { - registeredEditors = registeredEditors.filter(editor => !!editor.editorFactoryObject.createDiffEditorInput); - } + let registeredEditors = resource.scheme === Schemas.untitled + ? this._registeredEditors + .filter(editor => editor.editorInfo.priority.editor !== RegisteredEditorPriority.exclusive) + .filter(editor => associationType !== EditorAssociationType.DiffEditor || !!editor.editorFactoryObject.createDiffEditorInput) + .map(editor => editor.editorInfo) + : this.getEditorMatches(resource, { isDiffEditor: associationType === EditorAssociationType.DiffEditor }).matches.map(match => match.editor); // We don't want duplicate Id entries - registeredEditors = distinct(registeredEditors, c => c.editorInfo.id); - const defaultSetting = this.getAssociationsForResourceByType(resource, associationType)[0]?.viewType; + registeredEditors = distinct(registeredEditors, editor => editor.id); + const defaultRule = this.getEditorMatches(resource, { isDiffEditor: defaultAssociationType === EditorAssociationType.DiffEditor }).defaultRule; // Not the most efficient way to do this, but we want to ensure the text editor is at the top of the quickpick registeredEditors = registeredEditors.sort((a, b) => { - if (a.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + if (a.id === DEFAULT_EDITOR_ASSOCIATION.id) { return -1; - } else if (b.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + } else if (b.id === DEFAULT_EDITOR_ASSOCIATION.id) { return 1; } else { - return priorityToRank(this.getEffectivePriority(b.editorInfo, associationType)) - priorityToRank(this.getEffectivePriority(a.editorInfo, associationType)); + return priorityToRank(this.getEffectivePriority(b, associationType)) - priorityToRank(this.getEffectivePriority(a, associationType)); } }); const quickPickEntries: Array = []; const currentlyActiveLabel = localize('promptOpenWith.currentlyActive', "Active"); const currentDefaultLabel = localize('promptOpenWith.currentDefault', "Default"); const currentDefaultAndActiveLabel = localize('promptOpenWith.currentDefaultAndActive', "Active and Default"); - // Default order = setting -> highest priority -> text - let defaultViewType = defaultSetting; - if (!defaultViewType && registeredEditors.length > 2 && this.getEffectivePriority(registeredEditors[1].editorInfo, associationType) !== RegisteredEditorPriority.option) { - defaultViewType = registeredEditors[1]?.editorInfo.id; - } - if (!defaultViewType) { - defaultViewType = DEFAULT_EDITOR_ASSOCIATION.id; - } // Map the editors to quickpick entries registeredEditors.forEach(editor => { const currentViewType = currentEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id; - const isActive = currentEditor ? editor.editorInfo.id === currentViewType : false; - const isDefault = editor.editorInfo.id === defaultViewType; + const isActive = currentEditor ? editor.id === currentViewType : false; + const isDefault = editor.id === defaultRule.editor.id; const quickPickEntry: IQuickPickItem = { - id: editor.editorInfo.id, - label: editor.editorInfo.label, + id: editor.id, + label: editor.label, description: isActive && isDefault ? currentDefaultAndActiveLabel : isActive ? currentlyActiveLabel : isDefault ? currentDefaultLabel : undefined, - detail: editor.editorInfo.detail ?? editor.editorInfo.priority.editor, + detail: editor.detail ?? editor.priority.editor, }; quickPickEntries.push(quickPickEntry); }); if (!showDefaultPicker && extname(resource) !== '') { const separator: IQuickPickSeparator = { type: 'separator' }; quickPickEntries.push(separator); + const editorDefault = this.getEditorMatches(resource).defaultRule; const configureDefaultEntry = { id: EditorResolverService.configureDefaultID, - label: localize('promptOpenWith.configureDefault', "Configure default editor for '{0}'...", `*${extname(resource)}`), + label: localize('promptOpenWith.configureDefault', "Configure default editor for '{0}'...", editorDefault.associationPattern), }; quickPickEntries.push(configureDefaultEntry); // For diffs, additionally offer to configure a diff-only default so the choice does not // affect how the resource opens as a normal editor (writes to `diffEditorAssociations`). if (associationType === EditorAssociationType.DiffEditor) { + const diffEditorDefault = this.getEditorMatches(resource, { isDiffEditor: true }).defaultRule; const configureDefaultDiffEntry = { id: EditorResolverService.configureDefaultDiffID, - label: localize('promptOpenWith.configureDefaultDiff', "Configure default editor (diff only) for '{0}'...", `*${extname(resource)}`), + label: localize('promptOpenWith.configureDefaultDiff', "Configure default editor (diff only) for '{0}'...", diffEditorDefault.associationPattern), }; quickPickEntries.push(configureDefaultDiffEntry); } @@ -883,28 +995,31 @@ export class EditorResolverService extends Disposable implements IEditorResolver // so that the per-item gear button keeps writing to the matching setting, but the "Configure // default editor" entries can target a specific setting (general vs. diff-only). const updateSettingType = updateAssociationType ?? associationType; + const defaultRule = this.getEditorMatches(resource, { isDiffEditor: updateSettingType === EditorAssociationType.DiffEditor }).defaultRule; // Persists the picked editor as the default for this resource's glob. When the user configures // the general default from a diff context, any diff-only override for the same glob is cleared // so that the general default also takes effect for diffs. const persistDefaultAssociation = (editorID: string) => { - const globPattern = `*${extname(resource)}`; - this.updateUserAssociationsForType(updateSettingType, globPattern, editorID); + this.setDefaultEditor(resource, editorID, updateSettingType === EditorAssociationType.DiffEditor); if (updateSettingType === EditorAssociationType.Editor && associationType === EditorAssociationType.DiffEditor) { - this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, globPattern); + const diffAssociationPattern = this.getRawAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId)[0]?.filenamePattern; + if (diffAssociationPattern) { + this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, diffAssociationPattern); + } } }; // Get all the editors for the resource as quickpick entries - const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType); + const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType, updateSettingType); // Create the editor picker const disposables = new DisposableStore(); const editorPicker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const placeHolderMessage = showDefaultPicker ? (updateSettingType === EditorAssociationType.DiffEditor ? - localize('promptOpenWith.updateDefaultDiffPlaceHolder', "Select new default editor (diff only) for '{0}'", `*${extname(resource)}`) : - localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", `*${extname(resource)}`)) : + localize('promptOpenWith.updateDefaultDiffPlaceHolder', "Select new default editor (diff only) for '{0}'", defaultRule.associationPattern) : + localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", defaultRule.associationPattern)) : localize('promptOpenWith.placeHolder', "Select editor for '{0}'", basename(resource)); editorPicker.placeholder = placeHolderMessage; editorPicker.canAcceptInBackground = true; diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index 73467c02ce19e0..de13324904d112 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -166,6 +166,10 @@ export interface IEditorResolverServiceGetEditorsOptions { readonly isDiffEditor?: boolean; } +export interface IEditorResolverServiceGetEditorMatchesOptions { + readonly isDiffEditor?: boolean; +} + export interface IEditorResolverServiceGetAllEditorsOptions { /** * Excludes registrations whose editor priority is exclusive. @@ -173,6 +177,90 @@ export interface IEditorResolverServiceGetAllEditorsOptions { readonly excludeExclusiveEditors?: boolean; } +export const enum EditorMatchRuleSource { + UserAssociation, + EditorRegistration, + Fallback +} + +/** + * The effective rule that makes one editor choice available for a resource. + */ +export type EditorMatchRule = { + readonly editor: RegisteredEditorInfo; + readonly priority: RegisteredEditorPriority; + readonly associationPattern: string; +} & ( + | { readonly source: EditorMatchRuleSource.UserAssociation; readonly association: EditorAssociation } + | { readonly source: EditorMatchRuleSource.EditorRegistration; readonly globPattern: string | glob.IRelativePattern } + | { readonly source: EditorMatchRuleSource.Fallback } + ); + +export function isUnconfiguredUniversalOptionalEditorMatch(rule: EditorMatchRule): boolean { + return rule.source === EditorMatchRuleSource.EditorRegistration + && rule.globPattern === '*' + && rule.priority === RegisteredEditorPriority.option; +} + +function freezeEditorMatchRule(rule: EditorMatchRule): EditorMatchRule { + const editor = Object.freeze({ + ...rule.editor, + priority: Object.freeze({ ...rule.editor.priority }) + }); + switch (rule.source) { + case EditorMatchRuleSource.UserAssociation: + return Object.freeze({ ...rule, editor, association: Object.freeze({ ...rule.association }) }); + case EditorMatchRuleSource.EditorRegistration: + return Object.freeze({ + ...rule, + editor, + globPattern: typeof rule.globPattern === 'string' ? rule.globPattern : Object.freeze({ ...rule.globPattern }) + }); + case EditorMatchRuleSource.Fallback: + return Object.freeze({ ...rule, editor }); + } +} + +/** + * An immutable snapshot containing one effective rule per matching editor and the rule selecting its default. + */ +export class EditorMatches { + readonly matches: readonly EditorMatchRule[]; + readonly defaultRuleIndex: number; + readonly defaultRule: EditorMatchRule; + readonly naturalDefaultRuleIndex: number; + readonly naturalDefaultRule: EditorMatchRule; + readonly conflictingDefault: boolean; + readonly hasExclusiveMatch: boolean; + + constructor(matches: readonly EditorMatchRule[], defaultRuleIndex: number, naturalDefaultRuleIndex: number, conflictingDefault: boolean) { + if (defaultRuleIndex < 0 || defaultRuleIndex >= matches.length) { + throw new RangeError('The default editor rule must be an item in the matches array.'); + } + if (naturalDefaultRuleIndex < 0 || naturalDefaultRuleIndex >= matches.length) { + throw new RangeError('The natural default editor rule must be an item in the matches array.'); + } + if (new Set(matches.map(match => match.editor.id)).size !== matches.length) { + throw new RangeError('Each editor must have exactly one effective match rule.'); + } + if (conflictingDefault && matches[defaultRuleIndex].source !== EditorMatchRuleSource.EditorRegistration) { + throw new RangeError('Only a registered editor default can conflict with another default.'); + } + if (matches.some((match, index) => match.source === EditorMatchRuleSource.Fallback && index !== defaultRuleIndex && index !== naturalDefaultRuleIndex)) { + throw new RangeError('A fallback rule must select the effective or natural default editor.'); + } + + this.matches = Object.freeze(matches.map(freezeEditorMatchRule)); + this.defaultRuleIndex = defaultRuleIndex; + this.defaultRule = this.matches[defaultRuleIndex]; + this.naturalDefaultRuleIndex = naturalDefaultRuleIndex; + this.naturalDefaultRule = this.matches[naturalDefaultRuleIndex]; + this.conflictingDefault = conflictingDefault; + this.hasExclusiveMatch = this.matches.some(match => match.priority === RegisteredEditorPriority.exclusive); + Object.freeze(this); + } +} + export type RegisteredEditorPriorityInfo = { readonly editor: RegisteredEditorPriority; readonly diff: RegisteredEditorPriority; @@ -241,22 +329,19 @@ export interface IEditorResolverService { getAssociationsForResource(resource: URI): EditorAssociations; /** - * Returns the view type of the user-configured default editor for a resource, or `undefined` when - * none is configured. When `forDiffEditor` is `true` the diff editor association setting - * (`workbench.diffEditorAssociations`) is consulted instead of the general one. - * @param resource The resource to match - * @param forDiffEditor Whether to read the diff editor association setting + * Returns an immutable snapshot of the editors matching a resource and the rule selecting its default. */ - getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined; + getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches; /** - * Updates the user's association to include a specific editor ID as a default for the given glob pattern - * @param globPattern The glob pattern (must be a string as settings don't support relative glob) - * @param editorID The ID of the editor to make a user default + * Sets an editor as the default for a resource, removing a redundant association when restoring + * the natural default supplied by editor registrations or the Text Editor fallback. + * @param resource The resource whose editor default is changing. + * @param editorID The ID of the editor to make the default. * @param forDiffEditor When `true`, the diff editor association (`workbench.diffEditorAssociations`) * is updated instead of the general editor association (`workbench.editorAssociations`). */ - updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void; + setDefaultEditor(resource: URI, editorID: string, forDiffEditor?: boolean): void; /** * Emitted when an editor is registered or unregistered. diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index e4a376858277e8..224b0a32e79448 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -9,10 +9,11 @@ import { Schemas } from '../../../../../base/common/network.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { EditorPart } from '../../../../browser/parts/editor/editorPart.js'; +import { DEFAULT_EDITOR_ASSOCIATION } from '../../../../common/editor.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { EditorResolverService } from '../../browser/editorResolverService.js'; import { IEditorGroupsService } from '../../common/editorGroupsService.js'; -import { diffEditorsAssociationsAgentsWindowDefault, EditorInputFactoryObject, IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; +import { diffEditorsAssociationsAgentsWindowDefault, EditorInputFactoryObject, EditorMatchRuleSource, EditorMatches, IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { createEditorPart, ITestInstantiationService, TestFileEditorInput, TestServiceAccessor, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; @@ -913,6 +914,179 @@ suite('EditorResolverService', () => { }); }); + test('getEditorMatches derives the default rule from the complete match set', async () => { + const [, service] = await createEditorResolverService(); + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }), + createDiffEditorInput: ({ modified }) => ({ editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }) + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('file:/**/*.html', { + id: 'test.browser', + label: 'Browser', + priority: RegisteredEditorPriority.option + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: { + editor: RegisteredEditorPriority.default, + diff: RegisteredEditorPriority.explicit + } + }, {}, factory)); + + const componentResource = URI.file('/workspace/example.component.html'); + const htmlResource = URI.file('/workspace/example.html'); + const summarize = (matches: EditorMatches) => ({ + editorIds: matches.matches.map(match => match.editor.id), + defaultRuleIndex: matches.defaultRuleIndex, + defaultRule: { + editorId: matches.defaultRule.editor.id, + source: matches.defaultRule.source, + associationPattern: matches.defaultRule.associationPattern + }, + defaultIsArrayItem: matches.defaultRule === matches.matches[matches.defaultRuleIndex], + naturalDefaultEditorId: matches.naturalDefaultRule.editor.id, + naturalDefaultIsArrayItem: matches.naturalDefaultRule === matches.matches[matches.naturalDefaultRuleIndex], + immutable: Object.isFrozen(matches) + && Object.isFrozen(matches.matches) + && matches.matches.every(match => Object.isFrozen(match) && Object.isFrozen(match.editor) && Object.isFrozen(match.editor.priority)) + }); + assert.deepStrictEqual({ + component: summarize(service.getEditorMatches(componentResource)), + componentDiff: summarize(service.getEditorMatches(componentResource, { isDiffEditor: true })), + html: summarize(service.getEditorMatches(htmlResource)) + }, { + component: { + editorIds: ['test.componentEditor', DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser'], + defaultRuleIndex: 0, + defaultRule: { + editorId: 'test.componentEditor', + source: EditorMatchRuleSource.EditorRegistration, + associationPattern: '*.component.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: 'test.componentEditor', + naturalDefaultIsArrayItem: true, + immutable: true + }, + componentDiff: { + editorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser', 'test.componentEditor'], + defaultRuleIndex: 0, + defaultRule: { + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + source: EditorMatchRuleSource.Fallback, + associationPattern: '*.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultIsArrayItem: true, + immutable: true + }, + html: { + editorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser'], + defaultRuleIndex: 0, + defaultRule: { + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + source: EditorMatchRuleSource.Fallback, + associationPattern: '*.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultIsArrayItem: true, + immutable: true + } + }); + assert.throws(() => new EditorMatches([], 0, 0, false), RangeError); + }); + + test('getEditorMatches reports the user association that selected the default', async () => { + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }) + }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }) + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + + const matches = service.getEditorMatches(URI.file('/workspace/example.component.html')); + assert.deepStrictEqual({ + defaultIsArrayItem: matches.defaultRule === matches.matches[matches.defaultRuleIndex], + editorId: matches.defaultRule.editor.id, + naturalDefaultEditorId: matches.naturalDefaultRule.editor.id, + source: matches.defaultRule.source, + associationPattern: matches.defaultRule.associationPattern + }, { + defaultIsArrayItem: true, + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultEditorId: 'test.componentEditor', + source: EditorMatchRuleSource.UserAssociation, + associationPattern: '*.html' + }); + }); + + test('setDefaultEditor removes the association when restoring the natural registered default', async () => { + const configurationService = new class extends TestConfigurationService { + updateCount = 0; + + override async updateValue(key: string, value: unknown): Promise { + this.updateCount++; + await this.setUserConfiguration(key, value); + } + }({ + [editorsAssociationsSettingId]: { + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }) + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id, + updateCount: configurationService.updateCount + }, { + associations: [], + defaultEditorId: 'test.componentEditor', + updateCount: 1 + }); + }); + test('getEditors uses the effective diff priority', async () => { const [, service] = await createEditorResolverService(); const resource = URI.file('/workspace/index.html'); diff --git a/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts index 3618a7ffd12356..5e79afadc32ce3 100644 --- a/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SubmenuAction } from '../../../../../base/common/actions.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { DEFAULT_EDITOR_ASSOCIATION, IEditorInputWithDiffResources } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { getAvailableEditorTypes } from '../../../../browser/parts/editor/editorTypePicker.js'; -import { IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorsOptions, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { createEditorTypeActions, getAvailableEditorTypes } from '../../../../browser/parts/editor/editorTypePicker.js'; +import { EditorMatchRuleSource, EditorMatches, IEditorResolverService, IEditorResolverServiceGetEditorMatchesOptions, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; suite('Editor Type Picker', () => { @@ -28,6 +31,18 @@ suite('Editor Type Picker', () => { }; } + function editorMatches(editors: readonly RegisteredEditorInfo[], defaultEditorId = DEFAULT_EDITOR_ASSOCIATION.id): EditorMatches { + const matches = editors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration as const, + globPattern: '*.test', + associationPattern: '*.test' + })); + const defaultRuleIndex = matches.findIndex(match => match.editor.id === defaultEditorId); + return new EditorMatches(matches, defaultRuleIndex, defaultRuleIndex, false); + } + test('inline custom diff editor is classified as a diff editor', () => { const original = URI.file('/original/test.md'); const modified = URI.file('/modified/test.md'); @@ -35,6 +50,7 @@ suite('Editor Type Picker', () => { editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), editor('test.markdownEditor', RegisteredEditorPriority.option, RegisteredEditorPriority.explicit), ]; + const matches = editorMatches(registeredEditors); const input = disposables.add(new class extends EditorInput implements IEditorInputWithDiffResources { override get typeId(): string { return 'test.inlineCustomDiffEditor'; } override get editorId(): string { return 'test.markdownEditor'; } @@ -43,14 +59,12 @@ suite('Editor Type Picker', () => { override getName(): string { return 'test'; } }()); const requestedResources: URI[] = []; - const requestedOptions: (IEditorResolverServiceGetEditorsOptions | undefined)[] = []; + const requestedOptions: (IEditorResolverServiceGetEditorMatchesOptions | undefined)[] = []; const editorResolverService = new class extends mock() { - override getEditors(resourceOrOptions?: URI | IEditorResolverServiceGetAllEditorsOptions, options?: IEditorResolverServiceGetEditorsOptions): RegisteredEditorInfo[] { - if (URI.isUri(resourceOrOptions)) { - requestedResources.push(resourceOrOptions); - } + override getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches { + requestedResources.push(resource); requestedOptions.push(options); - return registeredEditors; + return matches; } }; @@ -59,8 +73,6 @@ suite('Editor Type Picker', () => { assert.deepStrictEqual({ requestedResources, requestedOptions, result }, { requestedResources: [modified], requestedOptions: [{ - excludeUnconfiguredUniversalOptionalEditors: true, - currentEditorId: 'test.markdownEditor', isDiffEditor: true, }], result: { @@ -69,6 +81,7 @@ suite('Editor Type Picker', () => { originalResource: original, modifiedResource: modified, currentId: 'test.markdownEditor', + editorMatches: matches, editors: registeredEditors, } }); @@ -80,6 +93,7 @@ suite('Editor Type Picker', () => { editor('test.markdownEditor', RegisteredEditorPriority.option), editor('test.markdownPreview', RegisteredEditorPriority.option), ]; + const matches = editorMatches(registeredEditors); class TestEditorInput extends EditorInput { constructor(private readonly id: string) { super(); @@ -91,8 +105,8 @@ suite('Editor Type Picker', () => { override getName(): string { return 'test'; } } const editorResolverService = new class extends mock() { - override getEditors(): RegisteredEditorInfo[] { - return registeredEditors; + override getEditorMatches(): EditorMatches { + return matches; } }; const markdownEditor = disposables.add(new TestEditorInput('test.markdownEditor')); @@ -108,4 +122,124 @@ suite('Editor Type Picker', () => { }); }); -}); \ No newline at end of file + test('exclusive matches suppress the editor type picker', () => { + const resource = URI.file('/workspace/test.hex'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.hexEditor', RegisteredEditorPriority.exclusive), + ]; + const matches = editorMatches(registeredEditors, 'test.hexEditor'); + const input = disposables.add(new class extends EditorInput { + override get typeId(): string { return 'test.hexEditorInput'; } + override get editorId(): string { return 'test.hexEditor'; } + override get resource(): URI { return resource; } + override getName(): string { return 'test'; } + }()); + const editorResolverService = new class extends mock() { + override getEditorMatches(): EditorMatches { + return matches; + } + }; + + assert.strictEqual(getAvailableEditorTypes(input, editorResolverService), undefined); + }); + + test('unconfigured universal optional matches only make the picker visible while active', () => { + const resource = URI.file('/workspace/test.md'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.universalPreview', RegisteredEditorPriority.option), + ]; + const matches = new EditorMatches(registeredEditors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: '*', + associationPattern: '*.md' + })), 0, 0, false); + class TestEditorInput extends EditorInput { + constructor(private readonly id: string) { + super(); + } + + override get typeId(): string { return 'test.editorInput'; } + override get editorId(): string { return this.id; } + override get resource(): URI { return resource; } + override getName(): string { return 'test'; } + } + const editorResolverService = new class extends mock() { + override getEditorMatches(): EditorMatches { + return matches; + } + }; + const textEditor = disposables.add(new TestEditorInput(DEFAULT_EDITOR_ASSOCIATION.id)); + const universalPreview = disposables.add(new TestEditorInput('test.universalPreview')); + + assert.deepStrictEqual({ + inactive: getAvailableEditorTypes(textEditor, editorResolverService), + activeEditorIds: getAvailableEditorTypes(universalPreview, editorResolverService)?.editors.map(editor => editor.id) + }, { + inactive: undefined, + activeEditorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.universalPreview'] + }); + }); + + test('set default uses the effective default scope instead of the active editor type', async () => { + const resource = URI.file('/workspace/example.component.html'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.componentEditor', RegisteredEditorPriority.default), + ]; + const updates: Array<{ resource: URI; editorId: string; forDiffEditor: boolean | undefined }> = []; + const commands: Array<{ id: string; args: unknown[] }> = []; + const matches = new EditorMatches(registeredEditors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: editor.id === 'test.componentEditor' ? '*.component.html' : '*', + associationPattern: editor.id === 'test.componentEditor' ? '*.component.html' : '*.html' + })), 1, 1, false); + const editorResolverService = new class extends mock() { + override setDefaultEditor(resource: URI, editorId: string, forDiffEditor?: boolean): void { + updates.push({ resource, editorId, forDiffEditor }); + } + }; + const commandService = new class extends mock() { + override async executeCommand(id: string, ...args: unknown[]): Promise { + commands.push({ id, args }); + return undefined; + } + }; + const actions = createEditorTypeActions({ + resource, + isDiffEditor: false, + currentId: DEFAULT_EDITOR_ASSOCIATION.id, + editorMatches: matches, + editors: registeredEditors + }, editorResolverService, commandService, new class extends mock() { }); + const setDefaultSubmenu = actions.find((action): action is SubmenuAction => action instanceof SubmenuAction); + assert.ok(setDefaultSubmenu); + + await setDefaultSubmenu.actions[0].run(); + + assert.deepStrictEqual({ + label: setDefaultSubmenu.label, + checked: setDefaultSubmenu.actions.map(action => action.checked), + updates, + commands + }, { + label: 'Set Default for \'*.component.html\'', + checked: [false, true], + updates: [{ + resource, + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + forDiffEditor: false + }], + commands: [{ + id: 'reopenActiveEditorWith', + args: [DEFAULT_EDITOR_ASSOCIATION.id] + }] + }); + }); + +}); From ae6fb7b94f995c554e8e4ccfba1bbaf4bda3486a Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 15:37:40 +0200 Subject: [PATCH 11/44] Fix editor resolver test typecheck Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c456dcf-e2d8-4cbe-9678-c0c0f25e3ccb --- .../editor/test/browser/editorResolverService.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index 224b0a32e79448..d1f734af10be83 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -918,7 +918,12 @@ suite('EditorResolverService', () => { const [, service] = await createEditorResolverService(); const factory: EditorInputFactoryObject = { createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }), - createDiffEditorInput: ({ modified }) => ({ editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }) + createDiffEditorInput: ({ modified }) => { + if (!modified.resource) { + throw new Error('Expected modified resource.'); + } + return { editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }; + } }; disposables.add(service.registerEditor('*', { id: DEFAULT_EDITOR_ASSOCIATION.id, From f950e422d37bf17a9153c114f056f0760b16a737 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 16:48:46 +0200 Subject: [PATCH 12/44] Preserve editor association precedence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c456dcf-e2d8-4cbe-9678-c0c0f25e3ccb --- .../editor/browser/editorResolverService.ts | 128 +++++++--- .../browser/editorResolverService.test.ts | 226 +++++++++++++++--- 2 files changed, 295 insertions(+), 59 deletions(-) diff --git a/src/vs/workbench/services/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index 0183d893b6097c..9177442c7fe17f 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -426,10 +426,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver } private getAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { - const matchingAssociations = this.getRawAssociationsForResourceFromSetting(resource, settingId); - const allEditors: RegisteredEditors = this._registeredEditors; - // Ensure that the settings are valid editors - return matchingAssociations.filter(association => allEditors.find(c => c.editorInfo.id === association.viewType)); + return this.getMatchingAssociationsForResource(resource, this.getAllUserAssociationsForSetting(settingId)); } private getRawAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { @@ -442,7 +439,15 @@ export class EditorResolverService extends Disposable implements IEditorResolver } private getRawAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { - const associations = this.getAllUserAssociationsForSetting(settingId); + return this.getMatchingRawAssociationsForResource(resource, this.getAllUserAssociationsForSetting(settingId)); + } + + private getMatchingAssociationsForResource(resource: URI, associations: EditorAssociations): EditorAssociations { + const matchingAssociations = this.getMatchingRawAssociationsForResource(resource, associations); + return matchingAssociations.filter(association => this._registeredEditors.some(editor => editor.editorInfo.id === association.viewType)); + } + + private getMatchingRawAssociationsForResource(resource: URI, associations: EditorAssociations): EditorAssociations { const matchingAssociations = associations.filter(association => association.filenamePattern && globMatchesResource(association.filenamePattern, resource)); // Sort matching associations based on glob length as a longer glob will be more specific return matchingAssociations.sort((a, b) => (b.filenamePattern?.length ?? 0) - (a.filenamePattern?.length ?? 0)); @@ -454,9 +459,18 @@ export class EditorResolverService extends Disposable implements IEditorResolver private getAllUserAssociationsForSetting(settingId: string): EditorAssociations { const inspectedEditorAssociations = this.configurationService.inspect<{ [fileNamePattern: string]: string }>(settingId) || {}; - const defaultAssociations = inspectedEditorAssociations.defaultValue ?? {}; - const workspaceAssociations = inspectedEditorAssociations.workspaceValue ?? {}; - const userAssociations = inspectedEditorAssociations.userValue ?? {}; + return this.mergeEditorAssociationSettings( + inspectedEditorAssociations.defaultValue ?? {}, + inspectedEditorAssociations.workspaceValue ?? {}, + inspectedEditorAssociations.userValue ?? {} + ); + } + + private mergeEditorAssociationSettings( + defaultAssociations: Readonly>, + workspaceAssociations: Readonly>, + userAssociations: Readonly> + ): EditorAssociations { const rawAssociations: { [fileNamePattern: string]: string } = { ...workspaceAssociations }; // We want to apply the default associations and user associations on top of the workspace associations but ignore duplicate keys. for (const [key, value] of Object.entries({ ...defaultAssociations, ...userAssociations })) { @@ -522,47 +536,97 @@ export class EditorResolverService extends Disposable implements IEditorResolver setDefaultEditor(resource: URI, editorID: string, forDiffEditor?: boolean): void { const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; + const associationType = forDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; const matches = this.getEditorMatches(resource, { isDiffEditor: forDiffEditor }); - const currentAssociation = this.getRawAssociationsForResourceFromSetting(resource, settingId)[0]; + const currentAssociation = matches.defaultRule.source === EditorMatchRuleSource.UserAssociation ? matches.defaultRule.association : undefined; + const associationPattern = matches.defaultRule.associationPattern; if (editorID === matches.naturalDefaultRule.editor.id) { - const inheritedAssociation = forDiffEditor ? this.getRawAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId)[0] : undefined; - if (currentAssociation && (!inheritedAssociation || inheritedAssociation.viewType === editorID)) { - this.removeUserAssociationForSetting(settingId, currentAssociation.filenamePattern!); + if (currentAssociation && this.getDefaultEditorIdAfterRemovingAssociation(resource, associationType, settingId, associationPattern) === editorID) { + this.removeUserAssociationForSetting(settingId, associationPattern); return; } - if (!currentAssociation && matches.defaultRule.editor.id === editorID) { + if (!currentAssociation && matches.defaultRule.editor.id === editorID && !matches.conflictingDefault) { return; } } - this.updateUserAssociationsForSetting(settingId, currentAssociation?.filenamePattern ?? matches.defaultRule.associationPattern, editorID); + this.updateUserAssociationsForSetting(settingId, associationPattern, editorID); } private updateUserAssociationsForSetting(settingId: string, globPattern: string, editorID: string): void { - const newAssociation: EditorAssociation = { viewType: editorID, filenamePattern: globPattern }; - const currentAssociations = this.getAllUserAssociationsForSetting(settingId); - const newSettingObject = Object.create(null); - // Form the new setting object including the newest associations - for (const association of [...currentAssociations, newAssociation]) { - if (association.filenamePattern) { - newSettingObject[association.filenamePattern] = association.viewType; - } - } + const newSettingObject = this.toEditorAssociationSetting(this.getAllUserAssociationsForSetting(settingId)); + newSettingObject[globPattern] = editorID; this.configurationService.updateValue(settingId, newSettingObject); } + private getDefaultEditorIdAfterRemovingAssociation(resource: URI, associationType: EditorAssociationType, settingId: string, globPattern: string): string | undefined { + const remainingSettingAssociations = this.getAssociationsAfterRemovingAssociation(settingId, globPattern); + if (!remainingSettingAssociations) { + return undefined; + } + + let associations = this.getMatchingAssociationsForResource(resource, remainingSettingAssociations); + if (associationType === EditorAssociationType.DiffEditor && associations.length === 0) { + associations = this.getAssociationsForResource(resource) + .filter(association => !this.isExplicitForAssociationType(association.viewType, associationType)); + } + + const editors = this.findMatchingEditors(resource, associationType, associations); + const selection = this.getDefaultEditorSelection(resource, associationType, false, editors, associations); + return selection.conflictingDefault ? undefined : selection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + } + + private getAssociationsAfterRemovingAssociation(settingId: string, globPattern: string): EditorAssociations | undefined { + const inspectedAssociations = this.configurationService.inspect>(settingId); + if (!inspectedAssociations) { + return undefined; + } + + const explicitUserTargetCount = Number(inspectedAssociations.userRemoteValue !== undefined) + + Number(inspectedAssociations.userLocalValue !== undefined); + const userTargetCount = explicitUserTargetCount || Number(inspectedAssociations.userValue !== undefined); + const configuredTargetCount = Number(inspectedAssociations.workspaceFolderValue !== undefined) + + Number(inspectedAssociations.workspaceValue !== undefined) + + userTargetCount + + Number(inspectedAssociations.applicationValue !== undefined); + if (configuredTargetCount !== 1) { + return undefined; + } + + const updatedSetting = this.toEditorAssociationSetting(this.getAllUserAssociationsForSetting(settingId), globPattern); + if (inspectedAssociations.workspaceValue !== undefined) { + return this.mergeEditorAssociationSettings( + inspectedAssociations.defaultValue ?? {}, + updatedSetting, + inspectedAssociations.userValue ?? {} + ); + } + if (userTargetCount === 1) { + return this.mergeEditorAssociationSettings( + inspectedAssociations.defaultValue ?? {}, + inspectedAssociations.workspaceValue ?? {}, + updatedSetting + ); + } + return undefined; + } + private removeUserAssociationForSetting(settingId: string, globPattern: string): void { const currentAssociations = this.getAllUserAssociationsForSetting(settingId); if (!currentAssociations.some(association => association.filenamePattern === globPattern)) { return; } - const newSettingObject = Object.create(null); - for (const association of currentAssociations) { - if (association.filenamePattern && association.filenamePattern !== globPattern) { - newSettingObject[association.filenamePattern] = association.viewType; + this.configurationService.updateValue(settingId, this.toEditorAssociationSetting(currentAssociations, globPattern)); + } + + private toEditorAssociationSetting(associations: EditorAssociations, excludedPattern?: string): Record { + const settingObject: Record = Object.create(null); + for (const association of associations) { + if (association.filenamePattern && association.filenamePattern !== excludedPattern) { + settingObject[association.filenamePattern] = association.viewType; } } - this.configurationService.updateValue(settingId, newSettingObject); + return settingObject; } private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor, userSettings = this.getAssociationsForResourceByType(resource, associationType)): RegisteredEditor[] { @@ -1001,11 +1065,13 @@ export class EditorResolverService extends Disposable implements IEditorResolver // the general default from a diff context, any diff-only override for the same glob is cleared // so that the general default also takes effect for diffs. const persistDefaultAssociation = (editorID: string) => { + const associationPattern = defaultRule.associationPattern; this.setDefaultEditor(resource, editorID, updateSettingType === EditorAssociationType.DiffEditor); if (updateSettingType === EditorAssociationType.Editor && associationType === EditorAssociationType.DiffEditor) { - const diffAssociationPattern = this.getRawAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId)[0]?.filenamePattern; - if (diffAssociationPattern) { - this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, diffAssociationPattern); + const matchingDiffAssociation = this.getRawAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId) + .find(association => association.filenamePattern === associationPattern); + if (matchingDiffAssociation?.filenamePattern) { + this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, matchingDiffAssociation.filenamePattern); } } }; diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index d1f734af10be83..ad502d16415705 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -63,6 +63,46 @@ suite('EditorResolverService', () => { undefined); } + function registerDefaultEditorTestEditors(service: EditorResolverService, additionalDefaultEditorId?: string): void { + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }), + createDiffEditorInput: ({ modified }) => { + if (!modified.resource) { + throw new Error('Expected modified resource.'); + } + return { editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }; + } + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + if (additionalDefaultEditorId) { + disposables.add(service.registerEditor('*.component.html', { + id: additionalDefaultEditorId, + label: 'Additional Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + } + } + + function createMutableConfigurationService(configuration: Record): TestConfigurationService & { readonly updateCount: number } { + return new class extends TestConfigurationService { + updateCount = 0; + + override async updateValue(key: string, value: unknown): Promise { + this.updateCount++; + await this.setUserConfiguration(key, value); + } + }(configuration); + } + test('Simple Resolve', async () => { const [part, service] = await createEditorResolverService(); const registeredEditor = service.registerEditor('*.test', @@ -1018,19 +1058,7 @@ suite('EditorResolverService', () => { }) }, disposables); const [, service] = await createEditorResolverService(instantiationService); - const factory: EditorInputFactoryObject = { - createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }) - }; - disposables.add(service.registerEditor('*', { - id: DEFAULT_EDITOR_ASSOCIATION.id, - label: DEFAULT_EDITOR_ASSOCIATION.displayName, - priority: RegisteredEditorPriority.builtin - }, {}, factory)); - disposables.add(service.registerEditor('*.component.html', { - id: 'test.componentEditor', - label: 'Component Editor', - priority: RegisteredEditorPriority.default - }, {}, factory)); + registerDefaultEditorTestEditors(service); const matches = service.getEditorMatches(URI.file('/workspace/example.component.html')); assert.deepStrictEqual({ @@ -1049,16 +1077,155 @@ suite('EditorResolverService', () => { }); test('setDefaultEditor removes the association when restoring the natural registered default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id, + updateCount: configurationService.updateCount + }, { + associations: [], + defaultEditorId: 'test.componentEditor', + updateCount: 1 + }); + }); + + test('setDefaultEditor retains the association when a broader rule masks the natural default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id, + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id + }, { + associations: [ + { filenamePattern: '*.html', viewType: DEFAULT_EDITOR_ASSOCIATION.id }, + { filenamePattern: '*.component.html', viewType: 'test.componentEditor' } + ], + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor retains the diff association when the inherited rule masks the natural default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id + }, + [diffEditorsAssociationsSettingId]: { + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor', true); + + assert.deepStrictEqual({ + diffAssociations: Object.entries(configurationService.getValue>(diffEditorsAssociationsSettingId) ?? {}), + defaultEditorId: service.getEditorMatches(resource, { isDiffEditor: true }).defaultRule.editor.id + }, { + diffAssociations: [['*.component.html', 'test.componentEditor']], + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor retains the association supplied by the default configuration layer', async () => { + const defaultAssociations = { '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id }; + let userAssociations: Record | undefined; const configurationService = new class extends TestConfigurationService { - updateCount = 0; + override inspect(key: string) { + if (key !== editorsAssociationsSettingId) { + return super.inspect(key); + } + const value = { ...defaultAssociations, ...userAssociations }; + return { + value: value as T, + defaultValue: defaultAssociations as T, + userValue: userAssociations as T | undefined, + userLocalValue: userAssociations as T | undefined + }; + } override async updateValue(key: string, value: unknown): Promise { - this.updateCount++; - await this.setUserConfiguration(key, value); + if (key !== editorsAssociationsSettingId || !value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Expected editor associations.'); + } + const entries = Object.entries(value); + if (!entries.every((entry): entry is [string, string] => typeof entry[1] === 'string')) { + throw new Error('Expected editor association values.'); + } + userAssociations = Object.fromEntries(entries); } - }({ + }(); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + userAssociations, + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id + }, { + userAssociations: { + '*.component.html': 'test.componentEditor' + }, + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor pins the selected editor when natural defaults conflict', async () => { + const configurationService = createMutableConfigurationService({}); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service, 'test.additionalComponentEditor'); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + service.setDefaultEditor(resource, 'test.componentEditor'); + + const matches = service.getEditorMatches(resource); + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: matches.defaultRule.editor.id, + conflictingDefault: matches.conflictingDefault, + updateCount: configurationService.updateCount + }, { + associations: [{ filenamePattern: '*.component.html', viewType: 'test.componentEditor' }], + defaultEditorId: 'test.componentEditor', + conflictingDefault: false, + updateCount: 2 + }); + }); + + test('setDefaultEditor ignores a more specific association for an unregistered editor', async () => { + const configurationService = createMutableConfigurationService({ [editorsAssociationsSettingId]: { - '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + '*.component.html': 'test.unregisteredEditor' } }); const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); @@ -1071,24 +1238,27 @@ suite('EditorResolverService', () => { label: DEFAULT_EDITOR_ASSOCIATION.displayName, priority: RegisteredEditorPriority.builtin }, {}, factory)); - disposables.add(service.registerEditor('*.component.html', { - id: 'test.componentEditor', - label: 'Component Editor', + disposables.add(service.registerEditor('*.html', { + id: 'test.htmlEditor', + label: 'HTML Editor', priority: RegisteredEditorPriority.default }, {}, factory)); const resource = URI.file('/workspace/example.component.html'); - service.setDefaultEditor(resource, 'test.componentEditor'); - service.setDefaultEditor(resource, 'test.componentEditor'); + const displayedAssociationPattern = service.getEditorMatches(resource).defaultRule.associationPattern; + service.setDefaultEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id); assert.deepStrictEqual({ + displayedAssociationPattern, associations: service.getAllUserAssociations(), - defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id, - updateCount: configurationService.updateCount + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id }, { - associations: [], - defaultEditorId: 'test.componentEditor', - updateCount: 1 + displayedAssociationPattern: '*.html', + associations: [ + { filenamePattern: '*.component.html', viewType: 'test.unregisteredEditor' }, + { filenamePattern: '*.html', viewType: DEFAULT_EDITOR_ASSOCIATION.id } + ], + defaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id }); }); From 3c83500c77bb4635f416e857093842fc5d3e9286 Mon Sep 17 00:00:00 2001 From: Ryan Ewen <1136808+RyanEwen@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:43:02 -0400 Subject: [PATCH 13/44] Report a browser tool failure with an empty message as a failure (#334311) `errorResult('')` left `toolResultError` falsy, so `toolResultToProtocol` serialized the call as `success: true` while the completed row still read "Browser action failed". Both `new Error()` and `throw ''` reach it as an empty string, from `playwrightInvoke` and two other call sites. Fall back to the failure label for the error and the content part, as the conversion above already does for `result.error`. Also corrects the comment on `failedMessage`. These tools do not all declare only an `invocationMessage`; the screenshot tool prepares a past-tense one, and that prepared label is what survives when a result carries no `toolResultMessage`. --- .../electron-browser/tools/browserToolHelpers.ts | 12 +++++------- .../tools/browserToolHelpers.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index c8ebc636de1e9e..b75785b204bdd0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -176,11 +176,8 @@ export async function playwrightInvoke( /** * Past-tense label for a browser tool call that failed. * - * These tools declare only an `invocationMessage`, so on completion the - * present-tense label is reused verbatim and a failed call reads as a - * successful one ("Capturing browser screenshot"). Naming the failure keeps - * the completed state honest, as the agent host already does for client tool - * calls and the codex mapper does for its own results. + * Without one, a completed call keeps whatever label the tool prepared, so a + * failure reads as a success. */ const failedMessage = localize('browser.actionFailed', "Browser action failed"); @@ -217,9 +214,10 @@ export function invokeFunctionResultToToolResult(result: IInvokeFunctionResult, } export function errorResult(message: string): IToolResult { + const error = message || failedMessage; return { - content: [{ kind: 'text', value: message }], - toolResultError: message, + content: [{ kind: 'text', value: error }], + toolResultError: error, toolResultMessage: failedMessage, }; } diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts index 82473356997522..eaf6a2b83bdb4d 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts @@ -44,6 +44,14 @@ suite('browserToolHelpers', () => { assert.ok(result.toolResultMessage); }); + test('errorResult with an empty message still reports a failure', () => { + // `throw ''` and `new Error()` both reach here as an empty string. + const result = errorResult(''); + + assert.ok(result.toolResultError, 'an empty error message is still a failure'); + assert.ok(result.content.some(part => part.kind === 'text' && part.value), 'the model needs a non-empty reason'); + }); + test('browser context explains active network filtering', () => { const editorService = upcastPartial({ activeEditor: undefined, From d536fa603e9c2fe8002340bc4a1b452d6ffc3244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dirk=20B=C3=A4umer?= Date: Thu, 3 Sep 2026 19:47:58 +0200 Subject: [PATCH 14/44] Add node paths for telemetry in region context (#334320) * Add node paths to the region for telemetry purpose * Address CCRs --- .../src/extension/tools/node/readFileTool.tsx | 22 +- .../common/serverProtocol.ts | 30 +- .../serverPlugin/src/common/protocol.ts | 28 +- .../src/common/regionContextProvider.ts | 42 +- .../serverPlugin/src/common/typescripts.ts | 4 +- .../serverPlugin/src/node/create.ts | 4 +- .../src/node/test/regionContext.spec.ts | 71 ++- .../vscode-node/regionContextProvider.ts | 4 +- .../vscode-node/ts6/regionContextProvider.ts | 6 +- .../vscode-node/ts7/regionContextProvider.ts | 40 +- .../ts7/test/regionContext.spec.ts | 54 +- .../vscode-node/ts7/typescripts.ts | 536 +++++++++++++++--- .../common/regionContextProvider.ts | 26 +- 13 files changed, 695 insertions(+), 172 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 06ce024474ab10..89e2b22a0be213 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -37,7 +37,7 @@ import { formatUriForFileWidget } from '../common/toolUtils'; import { getImageMimeType } from './imageToolUtils'; import { assertFileNotContentExcluded, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; import { IGrepResultService } from './grepResultService'; -import { IRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; +import { IRegionContextProviderService, type PathInfo, type RegionResult } from '../../../platform/languageContextProvider/common/regionContextProvider'; export const getReadFileV2Description = (orig: vscode.LanguageModelToolInformation): vscode.LanguageModelToolInformation => ({ name: ToolName.ReadFile, @@ -191,11 +191,12 @@ export class ReadFileTool implements ICopilotTool { try { const grepResultMatches = this.grepResultService.getGrepResult(options.chatRequestId, uri, startLine, endLine); if (grepResultMatches !== undefined && grepResultMatches.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { - const regions = await this.regionContextProvider.getRegions(documentSnapshot.uri, documentSnapshot.languageId, grepResultMatches, { start: startLine, end: endLine}); - if (regions !== undefined && regions.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end, documentSnapshot); + const regionResult: RegionResult | undefined = await this.regionContextProvider.getRegions(documentSnapshot.uri, documentSnapshot.languageId, grepResultMatches, { start: startLine, end: endLine}); + if (regionResult !== undefined && regionResult.regions.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { + const regions = regionResult.regions; + this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end, regionResult.paths, documentSnapshot); // const saving = (ranges.end - ranges.start) - (regions[0].range.end - regions[0].range.start); - // this.logService.info(`Saving ${saving} lines reading ${documentSnapshot.uri.fsPath}. Requests [${ranges.start}-${ranges.end}], Grep matches: [${grepResultMatches.map(m => m.start.line + 1).join(',')}], region [${regions[0].range.start + 1}-${regions[0].range.end + 1}]`); + // this.logService.info(`Saving ${saving} lines reading ${documentSnapshot.uri.fsPath}. Requests [${ranges.start}-${ranges.end}], Grep matches: [${grepResultMatches.map(m => m.start.line + 1).join(',')}], region [${regionResult.regions[0].range.start + 1}-${regionResult.regions[0].range.end + 1}]`); } else { if (documentSnapshot.version === documentSnapshot.document.version) { this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions', documentSnapshot); @@ -433,8 +434,11 @@ export class ReadFileTool implements ICopilotTool { } } - private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { + private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, pathInfo: PathInfo, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { const languageId = documentSnapshot.languageId; + const smallestPath: string = JSON.stringify(pathInfo.smallest); + const largestPath: string | undefined = pathInfo?.largest ? JSON.stringify(pathInfo.largest) : undefined; + /* __GDPR__ "readFileRegionAdjusted" : { "owner": "dbaeumer", @@ -444,13 +448,17 @@ export class ReadFileTool implements ICopilotTool { "adjustedLines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of lines after the requested region has been adjusted", "isMeasurement": true }, "deltaStart": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original start line and the adjusted start line", "isMeasurement": true }, "deltaEnd": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original end line and the adjusted end line", "isMeasurement": true }, - "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" }, + "smallestPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The smallest path in the region context" }, + "largestPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The largest path in the region context" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjusted', { requestId: options.chatRequestId, languageId, + smallestPath, + largestPath, }, { originalLines: originalEnd - originalStart + 1, diff --git a/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts b/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts index 462cf6684a83da..a01dbf4571e3d1 100644 --- a/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts +++ b/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts @@ -50,10 +50,26 @@ export type LineRange = { end: number; }; -export type Region = { +export interface Region { kind: string; name?: string; range: LineRange; +} + +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; }; export type WithinRangeCacheScope = { @@ -460,14 +476,16 @@ export interface RegionContextRequest extends tt.server.protocol.Request { } export namespace RegionContextResponse { - export type OK = { - regions: Region[]; - }; + export type OK = RegionResult; export type Failed = CustomResponse.Failed; export function isOk(response: RegionContextResponse | undefined): response is Omit & { body: OK } { - return response?.type === 'response' && Array.isArray((response.body as OK | undefined)?.regions); + if (response?.type !== 'response') { + return false; + } + const body = response.body as OK | undefined; + return Array.isArray(body?.regions) && Array.isArray(body?.paths?.smallest); } export function isError(response: RegionContextResponse | undefined): response is Omit & { body: Failed } { @@ -640,4 +658,4 @@ export namespace NesRenameResponse { export type NesRenameResponse = (tt.server.protocol.Response & { body: NesRenameResponse.OK | NesRenameResponse.Failed; -}) | { type: 'cancelled' }; \ No newline at end of file +}) | { type: 'cancelled' }; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts index 5240908cc9b42a..a01dbf4571e3d1 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts @@ -50,10 +50,26 @@ export type LineRange = { end: number; }; -export type Region = { +export interface Region { kind: string; name?: string; range: LineRange; +} + +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; }; export type WithinRangeCacheScope = { @@ -460,14 +476,16 @@ export interface RegionContextRequest extends tt.server.protocol.Request { } export namespace RegionContextResponse { - export type OK = { - regions: Region[]; - }; + export type OK = RegionResult; export type Failed = CustomResponse.Failed; export function isOk(response: RegionContextResponse | undefined): response is Omit & { body: OK } { - return response?.type === 'response' && Array.isArray((response.body as OK | undefined)?.regions); + if (response?.type !== 'response') { + return false; + } + const body = response.body as OK | undefined; + return Array.isArray(body?.regions) && Array.isArray(body?.paths?.smallest); } export function isError(response: RegionContextResponse | undefined): response is Omit & { body: Failed } { diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts index f225ce4dea312e..a6e3a227915ef2 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts @@ -6,32 +6,51 @@ import type tt from 'typescript/lib/tsserverlibrary'; import TS from './typescript'; const ts = TS(); -import type { LineRange, Range, Region } from './protocol'; +import { Region, type LineRange, type Range, type RegionResult } from './protocol'; import tss from './typescripts'; type StructuralEntity = { kind: string; name?: string; rangeNode: tt.Node | [tt.Node, tt.Node]; includeJsDoc?: boolean; continueWith?: tt.Node }; +type ScopeInfo = { + regions: Region[]; + path: number[]; +}; + export class RegionContextProvider { - public getRegions(sourceFile: tt.SourceFile, ranges: readonly Range[], requested?: LineRange | undefined): Region[] | undefined { + public getRegions(sourceFile: tt.SourceFile, ranges: readonly Range[], requested?: LineRange | undefined): RegionResult | undefined { if (ranges.length === 0) { return undefined; } if (ranges.length === 1) { - return this.findEnclosingScopes(sourceFile, ranges[0].start.line, ranges[0].start.character, requested); + const scope = this.findEnclosingScopes(sourceFile, ranges[0].start.line, ranges[0].start.character, requested); + return scope === undefined ? undefined : { + regions: scope.regions, + paths: { smallest: scope.path } + }; } + let smallest: { path: number[]; region: Region } | undefined; + let largest: { path: number[]; region: Region } | undefined; const containersList: Region[][] = []; for (const range of ranges) { - const containers = this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); - if (containers !== undefined && containers.length > 0) { - containersList.push(containers.reverse()); + const scope = this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); + if (scope !== undefined && scope.regions.length > 0) { + const { regions, path } = scope; + const region = regions[0]; + if (smallest === undefined || Region.getSpan(region) < Region.getSpan(smallest.region)) { + smallest = { region, path }; + } + if (largest === undefined || Region.getSpan(region) > Region.getSpan(largest.region)) { + largest = { region, path }; + } + containersList.push(regions.reverse()); } } if (containersList.length === 0) { return undefined; - } + } const longestContainers = containersList.reduce((longest, containers) => containers.length > longest.length ? containers : longest); const commonContainers = longestContainers.slice(); @@ -69,10 +88,13 @@ export class RegionContextProvider { } } - return commonContainers.reverse(); + return { + regions: commonContainers.reverse(), + paths: { smallest: smallest?.path ?? [], largest: largest?.path } + }; } - private findEnclosingScopes(sourceFile: tt.SourceFile, line: number, column: number, requested?: LineRange | undefined): Region[] | undefined { + private findEnclosingScopes(sourceFile: tt.SourceFile, line: number, column: number, requested?: LineRange | undefined): ScopeInfo | undefined { const position = sourceFile.getPositionOfLineAndCharacter(line, column); const tokenInfo = tss.getRelevantTokens(sourceFile, position); const node = tokenInfo.touching ?? tokenInfo.token; @@ -108,7 +130,7 @@ export class RegionContextProvider { current = continueWith ?? current; } } - return result.length > 0 ? result : undefined; + return result.length > 0 ? { regions: result, path: tss.StableSyntaxKinds.getPath(node) } : undefined; } private getStructuralEntity(sourceFile: tt.SourceFile, node: tt.Node, requested?: LineRange | undefined): StructuralEntity | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts index 61136b3596a426..7506695256c9ae 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts @@ -1882,6 +1882,8 @@ namespace tss { [ts.SyntaxKind.CommaListExpression, 355], [ts.SyntaxKind.SyntheticReferenceExpression, 356], [ts.SyntaxKind.NotEmittedTypeElement, 357], // New in 5.8.x. Position in 5.8 is 354 and the rest shifts. + [ts.SyntaxKind.DeferKeyword, 358], // New in 6.0.3 + [ts.SyntaxKind.Count, 359] ]); const UnknownStableSyntaxKind: number = 9999; export function getPath(node: tt.Node): number[] { @@ -1894,4 +1896,4 @@ namespace tss { } } } -export = tss; \ No newline at end of file +export = tss; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts index 8872e1fc05f3c6..63b0c1bb9f65ad 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts @@ -215,8 +215,8 @@ const regionContextHandler = (request: RegionContextRequest): RegionContextHandl try { const sourceFile = input.program.getSourceFile(input.file); - const regions = sourceFile === undefined ? [] : new RegionContextProvider().getRegions(sourceFile, request.arguments!.ranges, request.arguments!.requested) ?? []; - return { response: { regions }, responseRequired: true }; + const result = sourceFile === undefined ? undefined : new RegionContextProvider().getRegions(sourceFile, request.arguments!.ranges, request.arguments!.requested); + return { response: result ?? { regions: [], paths: { smallest: [] } }, responseRequired: true }; } catch (error) { if (error instanceof Error) { return { response: { error: ErrorCode.exception, message: error.message, stack: error.stack }, responseRequired: true }; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts index 9ebda67e380363..d69e68b7e72667 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts @@ -7,7 +7,7 @@ import { beforeAll, suite, test } from 'vitest'; import ts from 'typescript'; -import type { LineRange, Range, Region } from '../../common/protocol'; +import type { LineRange, Range, RegionResult } from '../../common/protocol'; import type * as regionContextProvider from '../../common/regionContextProvider'; let RegionContextProvider: typeof regionContextProvider.RegionContextProvider; @@ -18,7 +18,7 @@ beforeAll(async () => { RegionContextProvider = (await import('../../common/regionContextProvider')).RegionContextProvider; }); -function getRegionContext(sourceFile: ts.SourceFile, ranges: readonly Range[], requested?: LineRange): Region[] | undefined { +function getRegionContext(sourceFile: ts.SourceFile, ranges: readonly Range[], requested?: LineRange): RegionResult | undefined { return new RegionContextProvider().getRegions(sourceFile, ranges, requested); } @@ -41,12 +41,15 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(3)]), [ - { kind: 'arrow-function', name: 'callback', range: { start: 2, end: 4 } }, - { kind: 'method', name: 'method', range: { start: 1, end: 5 } }, - { kind: 'class', name: 'Container', range: { start: 0, end: 6 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 6 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(3)]), { + regions: [ + { kind: 'arrow-function', name: 'callback', range: { start: 2, end: 4 } }, + { kind: 'method', name: 'method', range: { start: 1, end: 5 } }, + { kind: 'class', name: 'Container', range: { start: 0, end: 6 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 6 } }, + ], + paths: { smallest: [241, 219, 260, 261, 243, 241, 174, 263, 307] } + } satisfies RegionResult); }); test('merges distinct innermost regions', () => { @@ -61,11 +64,39 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(5)]), [ - { kind: 'merged', range: { start: 1, end: 6 } }, - { kind: 'class', name: 'Container', range: { start: 0, end: 7 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 7 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(5)]), { + regions: [ + { kind: 'merged', range: { start: 1, end: 6 } }, + { kind: 'class', name: 'Container', range: { start: 0, end: 7 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 7 } }, + ], + paths: { + smallest: [241, 174, 263, 307], + largest: [241, 174, 263, 307] + } + } satisfies RegionResult); + }); + + test('selects paths by region span', () => { + const sourceFile = ts.createSourceFile('regions.ts', [ + 'class Container {', + '\tconstructor() {', + '\t\tthis.value = 0;', + '\t}', + '', + '\tmethod(): void {', + '\t\tconst value = 1;', + '\t\treturn;', + '\t}', + '}', + ].join('\n'), ts.ScriptTarget.Latest, true); + const smallest = getRegionContext(sourceFile, [range(2)])?.paths.smallest; + const largest = getRegionContext(sourceFile, [range(6)])?.paths.smallest; + + assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(6)])?.paths, { + smallest, + largest + }); }); test('groups property signatures within the requested range', () => { @@ -76,9 +107,15 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(1, 1), range(2, 1)], { start: 1, end: 2 }), [ - { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 3 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(1, 1), range(2, 1)], { start: 1, end: 2 }), { + regions: [ + { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 3 } }, + ], + paths: { + smallest: [80, 171, 264, 307], + largest: [80, 171, 264, 307] + } + } satisfies RegionResult); }); }); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts index 9ba4f7d05814ed..1eead1c8154447 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { type IRegionContextProviderService, type Region, type LineRange, NullRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; +import { type IRegionContextProviderService, type RegionResult, type LineRange, NullRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; import { TypeScript } from './tsService'; @@ -37,7 +37,7 @@ export class ContainerContextProviderService implements IRegionContextProviderSe this.disposables.dispose(); } - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { return this.provider.getRegions(document, languageId, ranges, requested); } diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts index 3d8daa0a59f5fd..a3135e8474ae0d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import type { IRegionContextProviderService, Region, LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; +import type { IRegionContextProviderService, RegionResult, LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; import * as protocol from '../../common/serverProtocol'; enum ExecutionTarget { @@ -25,7 +25,7 @@ type RegionContextRequestArgs = Omit, vscode.Disposable { private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; - async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { + async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { if (document.scheme !== 'file' || (languageId !== 'typescript' && languageId !== 'javascript')) { return undefined; } @@ -50,7 +50,7 @@ export class TS6RegionContextProvider implements Omit 0 ? response.body.regions : undefined; + return protocol.RegionContextResponse.isOk(response) && response.body.regions.length > 0 ? response.body : undefined; } dispose(): void { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts index 8c46ca8572b94b..d3969f7b4fbec1 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts @@ -8,13 +8,18 @@ import type { Snapshot } from '@typescript/native/unstable/async'; import * as ts from '@typescript/native/unstable/ast'; import type { ILogService } from '../../../../platform/log/common/logService'; -import { type IRegionContextProviderService, type Region, type LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; +import { Region, type IRegionContextProviderService, type RegionResult, type LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; import { TypeScript7Api } from './ts7Api'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import tss from './typescripts'; type StructuralEntity = { kind: string; name?: string; rangeNode: ts.Node | [ts.Node, ts.Node]; includeJsDoc?: boolean; continueWith?: ts.Node }; +type ScopeInfo = { + regions: Region[]; + path: number[]; +}; + interface RegionContextApi { clearSourceFileCache(): void; updateSnapshot(): Promise; @@ -34,7 +39,7 @@ export class TS7RegionContextProvider implements Omit { + async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { if (document.scheme !== 'file' || (languageId !== 'typescript' && languageId !== 'javascript')) { return undefined; } @@ -60,13 +65,27 @@ export class TS7RegionContextProvider implements Omit 0) { - containersList.push(containers.reverse()); + const scope = await this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); + if (scope !== undefined && scope.regions.length > 0) { + const { regions, path } = scope; + const region = regions[0]; + if (smallest === undefined || Region.getSpan(region) < Region.getSpan(smallest.region)) { + smallest = { region, path }; + } + if (largest === undefined || Region.getSpan(region) > Region.getSpan(largest.region)) { + largest = { path, region }; + } + containersList.push(regions.reverse()); } } if (containersList.length === 0) { @@ -109,14 +128,17 @@ export class TS7RegionContextProvider implements Omit { + private async findEnclosingScopes(sourceFile: ts.SourceFile, line: number, column: number, requested?: LineRange | undefined): Promise { const position = sourceFile.getPositionOfLineAndCharacter(line, column); const tokenInfo = tss.getRelevantTokens(sourceFile, position); const node = tokenInfo.touching ?? tokenInfo.token; @@ -152,7 +174,7 @@ export class TS7RegionContextProvider implements Omit 0 ? result : undefined; + return result.length > 0 ? { regions: result, path: tss.StableSyntaxKinds.getPath(node) } : undefined; } private getStructuralEntity(sourceFile: ts.SourceFile, node: ts.Node, requested?: LineRange | undefined): StructuralEntity | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts index 59bd35008caa0c..98ee3a3c6b5737 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts @@ -10,7 +10,7 @@ import { API } from '@typescript/native/unstable/async'; import * as vscode from 'vscode'; import { afterAll, beforeAll, suite, test } from 'vitest'; -import type { LineRange, Region } from '../../../../../platform/languageContextProvider/common/regionContextProvider'; +import type { LineRange, RegionResult } from '../../../../../platform/languageContextProvider/common/regionContextProvider'; import { TestLogService } from '../../../../../platform/testing/common/testLogService'; import { TS7RegionContextProvider } from '../regionContextProvider'; @@ -30,7 +30,7 @@ suite('TypeScript 7 region context', () => { await api.close(); }); - async function getRegions(ranges: vscode.Range[], requested?: LineRange): Promise { + async function getRegions(ranges: vscode.Range[], requested?: LineRange): Promise { const provider = new TS7RegionContextProvider(new TestLogService(), new TestTypeScript7Api(api, configFile)); try { return await provider.getRegions(vscode.Uri.file(fileName), 'typescript', ranges, requested); @@ -40,26 +40,48 @@ suite('TypeScript 7 region context', () => { } test('returns enclosing structural regions', async () => { - assert.deepStrictEqual(await getRegions([range(9, 2)]), [ - { kind: 'constructor', name: 'constructor', range: { start: 8, end: 10 } }, - { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(9, 2)]), { + regions: [ + { kind: 'constructor', name: 'constructor', range: { start: 8, end: 10 } }, + { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { smallest: [110, 211, 226, 244, 241, 176, 263, 307] } + } satisfies RegionResult); }); test('merges distinct innermost regions', async () => { - assert.deepStrictEqual(await getRegions([range(13, 2), range(18, 2)]), [ - { kind: 'merged', range: { start: 12, end: 22 } }, - { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(13, 2), range(18, 2)]), { + regions: [ + { kind: 'merged', range: { start: 12, end: 22 } }, + { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { + smallest: [110, 211, 226, 244, 241, 174, 263, 307], + largest: [107, 253, 241, 174, 263, 307] + } + } satisfies RegionResult); + }); + + test('selects paths by region span', async () => { + assert.deepStrictEqual((await getRegions([range(9, 2), range(13, 2)]))?.paths, { + smallest: [110, 211, 226, 244, 241, 176, 263, 307], + largest: [110, 211, 226, 244, 241, 174, 263, 307] + }); }); test('groups property signatures within the requested range', async () => { - assert.deepStrictEqual(await getRegions([range(1, 1), range(2, 1)], { start: 1, end: 2 }), [ - { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(1, 1), range(2, 1)], { start: 1, end: 2 }), { + regions: [ + { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { + smallest: [80, 171, 264, 307], + largest: [80, 171, 264, 307] + } + } satisfies RegionResult); }); }); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts index 2618205e497fea..784e8acc7fc66d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createHash } from 'node:crypto'; +import type * as vscode from 'vscode'; import { Symbol as NativeSymbol, SymbolFlags, type NodeHandle, type Program, type Project, type Type, type DocumentPosition } from '@typescript/native/unstable/async'; import { @@ -23,7 +24,6 @@ import { type TypeNode, type DeclarationBase } from '@typescript/native/unstable/ast'; -import type * as vscode from 'vscode'; export class OperationCanceledException extends Error { constructor() { @@ -55,94 +55,6 @@ export class CancellationTokenWithTimer { } } -namespace tss { - export type TokenInfo = { - token: Node; - touching?: Node; - previous?: Node; - }; - - export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { - const token = getTokenAtPosition(sourceFile, position); - const result: TokenInfo = { token }; - if (token.kind === SyntaxKind.EndOfFile) { - result.previous = findPrecedingToken(sourceFile, position); - return result; - } - - const start = token.getStart(sourceFile); - if (position > start) { - result.touching = token; - } else if (position < start) { - let candidate: Node | undefined = token.parent; - while (candidate !== undefined) { - if (position >= candidate.getStart(sourceFile)) { - result.touching = candidate; - break; - } - candidate = candidate.parent; - } - } - result.previous = findPrecedingToken(sourceFile, position); - return result; - } - - export namespace Nodes { - export function getChildren(node: Node): readonly Node[] { - if (isSourceFile(node)) { - return node.statements; - } - const result: Node[] = []; - node.forEachChild(child => { - result.push(child); - return undefined; - }); - return result; - } - - export function getTypeName(node: TypeNode): string | undefined { - return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; - } - - export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { - let current: Node | undefined = node; - while (current !== undefined) { - if (current.kind === kind) { - return current; - } - current = current.parent; - } - return undefined; - } - - export function getParentBlock(node: Node): Node | undefined { - let current: Node | undefined = node; - while (current !== undefined) { - if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { - return current; - } - current = current.parent; - } - return undefined; - } - } - - export namespace StableSyntaxKinds { - export function getPath(node: Node): number[] { - const result: number[] = []; - let current: Node | undefined = node; - while (current !== undefined) { - result.push(current.kind); - if (isSourceFile(current)) { - break; - } - current = current.parent; - } - return result; - } - } -} - export type TokenInfo = tss.TokenInfo; export type DirectSuperSymbolInfo = { @@ -484,4 +396,450 @@ export namespace Types { } } +namespace tss { + export type TokenInfo = { + token: Node; + touching?: Node; + previous?: Node; + }; + + export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { + const token = getTokenAtPosition(sourceFile, position); + const result: TokenInfo = { token }; + if (token.kind === SyntaxKind.EndOfFile) { + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + const start = token.getStart(sourceFile); + if (position > start) { + result.touching = token; + } else if (position < start) { + let candidate: Node | undefined = token.parent; + while (candidate !== undefined) { + if (position >= candidate.getStart(sourceFile)) { + result.touching = candidate; + break; + } + candidate = candidate.parent; + } + } + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + export namespace Nodes { + export function getChildren(node: Node): readonly Node[] { + if (isSourceFile(node)) { + return node.statements; + } + const result: Node[] = []; + node.forEachChild(child => { + result.push(child); + return undefined; + }); + return result; + } + + export function getTypeName(node: TypeNode): string | undefined { + return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; + } + + export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (current.kind === kind) { + return current; + } + current = current.parent; + } + return undefined; + } + + export function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; + } + } + + export namespace StableSyntaxKinds { + const KindMap: Map = new Map([ + [SyntaxKind.Unknown, 0], + // [SyntaxKind.EndOfFileToken, 1], + [SyntaxKind.SingleLineCommentTrivia, 2], + [SyntaxKind.MultiLineCommentTrivia, 3], + [SyntaxKind.NewLineTrivia, 4], + [SyntaxKind.WhitespaceTrivia, 5], + // [SyntaxKind.ShebangTrivia, 6], + [SyntaxKind.ConflictMarkerTrivia, 7], + [SyntaxKind.NonTextFileMarkerTrivia, 8], + [SyntaxKind.NumericLiteral, 9], + [SyntaxKind.BigIntLiteral, 10], + [SyntaxKind.StringLiteral, 11], + [SyntaxKind.JsxText, 12], + [SyntaxKind.JsxTextAllWhiteSpaces, 13], + [SyntaxKind.RegularExpressionLiteral, 14], + [SyntaxKind.NoSubstitutionTemplateLiteral, 15], + [SyntaxKind.TemplateHead, 16], + [SyntaxKind.TemplateMiddle, 17], + [SyntaxKind.TemplateTail, 18], + [SyntaxKind.OpenBraceToken, 19], + [SyntaxKind.CloseBraceToken, 20], + [SyntaxKind.OpenParenToken, 21], + [SyntaxKind.CloseParenToken, 22], + [SyntaxKind.OpenBracketToken, 23], + [SyntaxKind.CloseBracketToken, 24], + [SyntaxKind.DotToken, 25], + [SyntaxKind.DotDotDotToken, 26], + [SyntaxKind.SemicolonToken, 27], + [SyntaxKind.CommaToken, 28], + [SyntaxKind.QuestionDotToken, 29], + [SyntaxKind.LessThanToken, 30], + [SyntaxKind.LessThanSlashToken, 31], + [SyntaxKind.GreaterThanToken, 32], + [SyntaxKind.LessThanEqualsToken, 33], + [SyntaxKind.GreaterThanEqualsToken, 34], + [SyntaxKind.EqualsEqualsToken, 35], + [SyntaxKind.ExclamationEqualsToken, 36], + [SyntaxKind.EqualsEqualsEqualsToken, 37], + [SyntaxKind.ExclamationEqualsEqualsToken, 38], + [SyntaxKind.EqualsGreaterThanToken, 39], + [SyntaxKind.PlusToken, 40], + [SyntaxKind.MinusToken, 41], + [SyntaxKind.AsteriskToken, 42], + [SyntaxKind.AsteriskAsteriskToken, 43], + [SyntaxKind.SlashToken, 44], + [SyntaxKind.PercentToken, 45], + [SyntaxKind.PlusPlusToken, 46], + [SyntaxKind.MinusMinusToken, 47], + [SyntaxKind.LessThanLessThanToken, 48], + [SyntaxKind.GreaterThanGreaterThanToken, 49], + [SyntaxKind.GreaterThanGreaterThanGreaterThanToken, 50], + [SyntaxKind.AmpersandToken, 51], + [SyntaxKind.BarToken, 52], + [SyntaxKind.CaretToken, 53], + [SyntaxKind.ExclamationToken, 54], + [SyntaxKind.TildeToken, 55], + [SyntaxKind.AmpersandAmpersandToken, 56], + [SyntaxKind.BarBarToken, 57], + [SyntaxKind.QuestionToken, 58], + [SyntaxKind.ColonToken, 59], + [SyntaxKind.AtToken, 60], + [SyntaxKind.QuestionQuestionToken, 61], + [SyntaxKind.BacktickToken, 62], + [SyntaxKind.HashToken, 63], + [SyntaxKind.EqualsToken, 64], + [SyntaxKind.PlusEqualsToken, 65], + [SyntaxKind.MinusEqualsToken, 66], + [SyntaxKind.AsteriskEqualsToken, 67], + [SyntaxKind.AsteriskAsteriskEqualsToken, 68], + [SyntaxKind.SlashEqualsToken, 69], + [SyntaxKind.PercentEqualsToken, 70], + [SyntaxKind.LessThanLessThanEqualsToken, 71], + [SyntaxKind.GreaterThanGreaterThanEqualsToken, 72], + [SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, 73], + [SyntaxKind.AmpersandEqualsToken, 74], + [SyntaxKind.BarEqualsToken, 75], + [SyntaxKind.BarBarEqualsToken, 76], + [SyntaxKind.AmpersandAmpersandEqualsToken, 77], + [SyntaxKind.QuestionQuestionEqualsToken, 78], + [SyntaxKind.CaretEqualsToken, 79], + [SyntaxKind.Identifier, 80], + [SyntaxKind.PrivateIdentifier, 81], + [SyntaxKind.BreakKeyword, 83], + [SyntaxKind.CaseKeyword, 84], + [SyntaxKind.CatchKeyword, 85], + [SyntaxKind.ClassKeyword, 86], + [SyntaxKind.ConstKeyword, 87], + [SyntaxKind.ContinueKeyword, 88], + [SyntaxKind.DebuggerKeyword, 89], + [SyntaxKind.DefaultKeyword, 90], + [SyntaxKind.DeleteKeyword, 91], + [SyntaxKind.DoKeyword, 92], + [SyntaxKind.ElseKeyword, 93], + [SyntaxKind.EnumKeyword, 94], + [SyntaxKind.ExportKeyword, 95], + [SyntaxKind.ExtendsKeyword, 96], + [SyntaxKind.FalseKeyword, 97], + [SyntaxKind.FinallyKeyword, 98], + [SyntaxKind.ForKeyword, 99], + [SyntaxKind.FunctionKeyword, 100], + [SyntaxKind.IfKeyword, 101], + [SyntaxKind.ImportKeyword, 102], + [SyntaxKind.InKeyword, 103], + [SyntaxKind.InstanceOfKeyword, 104], + [SyntaxKind.NewKeyword, 105], + [SyntaxKind.NullKeyword, 106], + [SyntaxKind.ReturnKeyword, 107], + [SyntaxKind.SuperKeyword, 108], + [SyntaxKind.SwitchKeyword, 109], + [SyntaxKind.ThisKeyword, 110], + [SyntaxKind.ThrowKeyword, 111], + [SyntaxKind.TrueKeyword, 112], + [SyntaxKind.TryKeyword, 113], + [SyntaxKind.TypeOfKeyword, 114], + [SyntaxKind.VarKeyword, 115], + [SyntaxKind.VoidKeyword, 116], + [SyntaxKind.WhileKeyword, 117], + [SyntaxKind.WithKeyword, 118], + [SyntaxKind.ImplementsKeyword, 119], + [SyntaxKind.InterfaceKeyword, 120], + [SyntaxKind.LetKeyword, 121], + [SyntaxKind.PackageKeyword, 122], + [SyntaxKind.PrivateKeyword, 123], + [SyntaxKind.ProtectedKeyword, 124], + [SyntaxKind.PublicKeyword, 125], + [SyntaxKind.StaticKeyword, 126], + [SyntaxKind.YieldKeyword, 127], + [SyntaxKind.AbstractKeyword, 128], + [SyntaxKind.AccessorKeyword, 129], + [SyntaxKind.AsKeyword, 130], + [SyntaxKind.AssertsKeyword, 131], + [SyntaxKind.AssertKeyword, 132], + [SyntaxKind.AnyKeyword, 133], + [SyntaxKind.AsyncKeyword, 134], + [SyntaxKind.AwaitKeyword, 135], + [SyntaxKind.BooleanKeyword, 136], + [SyntaxKind.ConstructorKeyword, 137], + [SyntaxKind.DeclareKeyword, 138], + [SyntaxKind.GetKeyword, 139], + [SyntaxKind.InferKeyword, 140], + [SyntaxKind.IntrinsicKeyword, 141], + [SyntaxKind.IsKeyword, 142], + [SyntaxKind.KeyOfKeyword, 143], + [SyntaxKind.ModuleKeyword, 144], + [SyntaxKind.NamespaceKeyword, 145], + [SyntaxKind.NeverKeyword, 146], + [SyntaxKind.OutKeyword, 147], + [SyntaxKind.ReadonlyKeyword, 148], + [SyntaxKind.RequireKeyword, 149], + [SyntaxKind.NumberKeyword, 150], + [SyntaxKind.ObjectKeyword, 151], + [SyntaxKind.SatisfiesKeyword, 152], + [SyntaxKind.SetKeyword, 153], + [SyntaxKind.StringKeyword, 154], + [SyntaxKind.SymbolKeyword, 155], + [SyntaxKind.TypeKeyword, 156], + [SyntaxKind.UndefinedKeyword, 157], + [SyntaxKind.UniqueKeyword, 158], + [SyntaxKind.UnknownKeyword, 159], + [SyntaxKind.UsingKeyword, 160], + [SyntaxKind.FromKeyword, 161], + [SyntaxKind.GlobalKeyword, 162], + [SyntaxKind.BigIntKeyword, 163], + [SyntaxKind.OverrideKeyword, 164], + [SyntaxKind.OfKeyword, 165], + [SyntaxKind.QualifiedName, 166], + [SyntaxKind.ComputedPropertyName, 167], + [SyntaxKind.TypeParameter, 168], + [SyntaxKind.Parameter, 169], + [SyntaxKind.Decorator, 170], + [SyntaxKind.PropertySignature, 171], + [SyntaxKind.PropertyDeclaration, 172], + [SyntaxKind.MethodSignature, 173], + [SyntaxKind.MethodDeclaration, 174], + [SyntaxKind.ClassStaticBlockDeclaration, 175], + [SyntaxKind.Constructor, 176], + [SyntaxKind.GetAccessor, 177], + [SyntaxKind.SetAccessor, 178], + [SyntaxKind.CallSignature, 179], + [SyntaxKind.ConstructSignature, 180], + [SyntaxKind.IndexSignature, 181], + [SyntaxKind.TypePredicate, 182], + [SyntaxKind.TypeReference, 183], + [SyntaxKind.FunctionType, 184], + [SyntaxKind.ConstructorType, 185], + [SyntaxKind.TypeQuery, 186], + [SyntaxKind.TypeLiteral, 187], + [SyntaxKind.ArrayType, 188], + [SyntaxKind.TupleType, 189], + [SyntaxKind.OptionalType, 190], + [SyntaxKind.RestType, 191], + [SyntaxKind.UnionType, 192], + [SyntaxKind.IntersectionType, 193], + [SyntaxKind.ConditionalType, 194], + [SyntaxKind.InferType, 195], + [SyntaxKind.ParenthesizedType, 196], + [SyntaxKind.ThisType, 197], + [SyntaxKind.TypeOperator, 198], + [SyntaxKind.IndexedAccessType, 199], + [SyntaxKind.MappedType, 200], + [SyntaxKind.LiteralType, 201], + [SyntaxKind.NamedTupleMember, 202], + [SyntaxKind.TemplateLiteralType, 203], + [SyntaxKind.TemplateLiteralTypeSpan, 204], + [SyntaxKind.ImportType, 205], + [SyntaxKind.ObjectBindingPattern, 206], + [SyntaxKind.ArrayBindingPattern, 207], + [SyntaxKind.BindingElement, 208], + [SyntaxKind.ArrayLiteralExpression, 209], + [SyntaxKind.ObjectLiteralExpression, 210], + [SyntaxKind.PropertyAccessExpression, 211], + [SyntaxKind.ElementAccessExpression, 212], + [SyntaxKind.CallExpression, 213], + [SyntaxKind.NewExpression, 214], + [SyntaxKind.TaggedTemplateExpression, 215], + [SyntaxKind.TypeAssertionExpression, 216], + [SyntaxKind.ParenthesizedExpression, 217], + [SyntaxKind.FunctionExpression, 218], + [SyntaxKind.ArrowFunction, 219], + [SyntaxKind.DeleteExpression, 220], + [SyntaxKind.TypeOfExpression, 221], + [SyntaxKind.VoidExpression, 222], + [SyntaxKind.AwaitExpression, 223], + [SyntaxKind.PrefixUnaryExpression, 224], + [SyntaxKind.PostfixUnaryExpression, 225], + [SyntaxKind.BinaryExpression, 226], + [SyntaxKind.ConditionalExpression, 227], + [SyntaxKind.TemplateExpression, 228], + [SyntaxKind.YieldExpression, 229], + [SyntaxKind.SpreadElement, 230], + [SyntaxKind.ClassExpression, 231], + [SyntaxKind.OmittedExpression, 232], + [SyntaxKind.ExpressionWithTypeArguments, 233], + [SyntaxKind.AsExpression, 234], + [SyntaxKind.NonNullExpression, 235], + [SyntaxKind.MetaProperty, 236], + [SyntaxKind.SyntheticExpression, 237], + [SyntaxKind.SatisfiesExpression, 238], + [SyntaxKind.TemplateSpan, 239], + [SyntaxKind.SemicolonClassElement, 240], + [SyntaxKind.Block, 241], + [SyntaxKind.EmptyStatement, 242], + [SyntaxKind.VariableStatement, 243], + [SyntaxKind.ExpressionStatement, 244], + [SyntaxKind.IfStatement, 245], + [SyntaxKind.DoStatement, 246], + [SyntaxKind.WhileStatement, 247], + [SyntaxKind.ForStatement, 248], + [SyntaxKind.ForInStatement, 249], + [SyntaxKind.ForOfStatement, 250], + [SyntaxKind.ContinueStatement, 251], + [SyntaxKind.BreakStatement, 252], + [SyntaxKind.ReturnStatement, 253], + [SyntaxKind.WithStatement, 254], + [SyntaxKind.SwitchStatement, 255], + [SyntaxKind.LabeledStatement, 256], + [SyntaxKind.ThrowStatement, 257], + [SyntaxKind.TryStatement, 258], + [SyntaxKind.DebuggerStatement, 259], + [SyntaxKind.VariableDeclaration, 260], + [SyntaxKind.VariableDeclarationList, 261], + [SyntaxKind.FunctionDeclaration, 262], + [SyntaxKind.ClassDeclaration, 263], + [SyntaxKind.InterfaceDeclaration, 264], + [SyntaxKind.TypeAliasDeclaration, 265], + [SyntaxKind.EnumDeclaration, 266], + [SyntaxKind.ModuleDeclaration, 267], + [SyntaxKind.ModuleBlock, 268], + [SyntaxKind.CaseBlock, 269], + [SyntaxKind.NamespaceExportDeclaration, 270], + [SyntaxKind.ImportEqualsDeclaration, 271], + [SyntaxKind.ImportDeclaration, 272], + [SyntaxKind.ImportClause, 273], + [SyntaxKind.NamespaceImport, 274], + [SyntaxKind.NamedImports, 275], + [SyntaxKind.ImportSpecifier, 276], + [SyntaxKind.ExportAssignment, 277], + [SyntaxKind.ExportDeclaration, 278], + [SyntaxKind.NamedExports, 279], + [SyntaxKind.NamespaceExport, 280], + [SyntaxKind.ExportSpecifier, 281], + [SyntaxKind.MissingDeclaration, 282], + [SyntaxKind.ExternalModuleReference, 283], + [SyntaxKind.JsxElement, 284], + [SyntaxKind.JsxSelfClosingElement, 285], + [SyntaxKind.JsxOpeningElement, 286], + [SyntaxKind.JsxClosingElement, 287], + [SyntaxKind.JsxFragment, 288], + [SyntaxKind.JsxOpeningFragment, 289], + [SyntaxKind.JsxClosingFragment, 290], + [SyntaxKind.JsxAttribute, 291], + [SyntaxKind.JsxAttributes, 292], + [SyntaxKind.JsxSpreadAttribute, 293], + [SyntaxKind.JsxExpression, 294], + [SyntaxKind.JsxNamespacedName, 295], + [SyntaxKind.CaseClause, 296], + [SyntaxKind.DefaultClause, 297], + [SyntaxKind.HeritageClause, 298], + [SyntaxKind.CatchClause, 299], + [SyntaxKind.ImportAttributes, 300], + [SyntaxKind.ImportAttribute, 301], + [SyntaxKind.PropertyAssignment, 303], + [SyntaxKind.ShorthandPropertyAssignment, 304], + [SyntaxKind.SpreadAssignment, 305], + [SyntaxKind.EnumMember, 306], + [SyntaxKind.SourceFile, 307], + // [SyntaxKind.Bundle, 308], + [SyntaxKind.JSDocTypeExpression, 309], + [SyntaxKind.JSDocNameReference, 310], + // [SyntaxKind.JSDocMemberName, 311], + [SyntaxKind.JSDocAllType, 312], + // [SyntaxKind.JSDocUnknownType, 313], + [SyntaxKind.JSDocNullableType, 314], + [SyntaxKind.JSDocNonNullableType, 315], + [SyntaxKind.JSDocOptionalType, 316], + // [SyntaxKind.JSDocFunctionType, 317], + [SyntaxKind.JSDocVariadicType, 318], + // [SyntaxKind.JSDocNamepathType, 319], + [SyntaxKind.JSDoc, 320], + [SyntaxKind.JSDocText, 321], + [SyntaxKind.JSDocTypeLiteral, 322], + [SyntaxKind.JSDocSignature, 323], + [SyntaxKind.JSDocLink, 324], + [SyntaxKind.JSDocLinkCode, 325], + [SyntaxKind.JSDocLinkPlain, 326], + // [SyntaxKind.JSDocTag, 327], + [SyntaxKind.JSDocAugmentsTag, 328], + [SyntaxKind.JSDocImplementsTag, 329], + // [SyntaxKind.JSDocAuthorTag, 330], + [SyntaxKind.JSDocDeprecatedTag, 331], + // [SyntaxKind.JSDocClassTag, 332], + [SyntaxKind.JSDocPublicTag, 333], + [SyntaxKind.JSDocPrivateTag, 334], + [SyntaxKind.JSDocProtectedTag, 335], + [SyntaxKind.JSDocReadonlyTag, 336], + [SyntaxKind.JSDocOverrideTag, 337], + [SyntaxKind.JSDocCallbackTag, 338], + [SyntaxKind.JSDocOverloadTag, 339], + // [SyntaxKind.JSDocEnumTag, 340], + [SyntaxKind.JSDocParameterTag, 341], + [SyntaxKind.JSDocReturnTag, 342], + [SyntaxKind.JSDocThisTag, 343], + [SyntaxKind.JSDocTypeTag, 344], + [SyntaxKind.JSDocTemplateTag, 345], + [SyntaxKind.JSDocTypedefTag, 346], + [SyntaxKind.JSDocSeeTag, 347], + [SyntaxKind.JSDocPropertyTag, 348], + [SyntaxKind.JSDocThrowsTag, 349], + [SyntaxKind.JSDocSatisfiesTag, 350], + [SyntaxKind.JSDocImportTag, 351], + [SyntaxKind.SyntaxList, 352], + [SyntaxKind.NotEmittedStatement, 353], + [SyntaxKind.PartiallyEmittedExpression, 354], + // [SyntaxKind.CommaListExpression, 355], + [SyntaxKind.SyntheticReferenceExpression, 356], + [SyntaxKind.NotEmittedTypeElement, 357], // New in 5.8.x. Position in 5.8 is 354 and the rest shifts + [SyntaxKind.DeferKeyword, 358], // New in 6.0.3 + [SyntaxKind.Count, 359] + ]); + + const UnknownStableSyntaxKind: number = 9999; + export function getPath(node: Node): number[] { + const path: number[] = []; + while (node !== undefined) { + path.push(KindMap.get(node.kind) ?? UnknownStableSyntaxKind); + node = node.parent; + } + return path; + } + } +} + export default tss; diff --git a/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts b/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts index 2c7454f6d9c995..7b7e2670d3c152 100644 --- a/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts +++ b/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts @@ -6,10 +6,10 @@ import type * as vscode from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; -export interface LineRange { +export type LineRange = { start: number; end: number; -} +}; export interface Region { kind: string; @@ -17,19 +17,35 @@ export interface Region { range: LineRange; } +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; +}; + export const IRegionContextProviderService = createServiceIdentifier('IRegionContextProviderService'); export interface IRegionContextProviderService extends vscode.Disposable { readonly _serviceBrand: undefined; - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[]): Promise; - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise; + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[]): Promise; + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise; } export class NullRegionContextProviderService implements IRegionContextProviderService { readonly _serviceBrand: undefined; - async getRegions(): Promise { + async getRegions(): Promise { return undefined; } From 25131ddc3037cd7b7fd0b744899328d6cf53d829 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 3 Sep 2026 18:57:14 +0100 Subject: [PATCH 15/44] Add layout density options for modern UI in `Customize Layout` menu (#334322) * feat(layout): add layout density options for modern UI * layout: add icons for default and compact layout density options --------- Co-authored-by: mrleemurray --- .../browser/actions/layoutActions.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index d03a5c11880b72..f7b82f298f7069 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -8,7 +8,7 @@ import { MenuId, MenuRegistry, registerAction2, Action2 } from '../../../platfor import { Categories } from '../../../platform/action/common/actionCommonCategories.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { alert } from '../../../base/browser/ui/aria/aria.js'; -import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, Parts, Position, ZenModeSettings, positionToString } from '../../services/layout/browser/layoutService.js'; +import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, ModernUIDensity, Parts, Position, ZenModeSettings, positionToString } from '../../services/layout/browser/layoutService.js'; import { ServicesAccessor, IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { isWindows, isLinux, isWeb, isMacintosh, isNative } from '../../../base/common/platform.js'; @@ -54,6 +54,9 @@ const panelAlignmentJustifyIcon = registerIcon('panel-align-justify', Codicon.la const quickInputAlignmentTopIcon = registerIcon('quickInputAlignmentTop', Codicon.arrowUp, localize('quickInputAlignmentTop', "Represents quick input alignment set to the top")); const quickInputAlignmentCenterIcon = registerIcon('quickInputAlignmentCenter', Codicon.circle, localize('quickInputAlignmentCenter', "Represents quick input alignment set to the center")); +const layoutDensityDefaultIcon = registerIcon('layout-density-default-icon', Codicon.layoutDensityDefault, localize('layoutDensityDefaultIcon', "Represents the default layout density")); +const layoutDensityCompactIcon = registerIcon('layout-density-compact-icon', Codicon.layoutDensityCompact, localize('layoutDensityCompactIcon', "Represents the compact layout density")); + const fullscreenIcon = registerIcon('fullscreen', Codicon.screenFull, localize('fullScreenIcon', "Represents full screen")); const centerLayoutIcon = registerIcon('centerLayoutIcon', Codicon.layoutCentered, localize('centerLayoutIcon', "Represents centered layout mode")); const zenModeIcon = registerIcon('zenMode', Codicon.target, localize('zenModeIcon', "Represents zen mode")); @@ -1385,6 +1388,13 @@ const QuickInputActions: CustomizeLayoutItem[] = [ CreateOptionLayoutItem('workbench.action.alignQuickInputCenter', QuickInputAlignmentContextKey.isEqualTo('center'), localize('center', "Center"), quickInputAlignmentCenterIcon), ]; +const ModernUIEnabledContext = ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI}`, true); + +const LayoutDensityActions: CustomizeLayoutItem[] = [ + CreateOptionLayoutItem(`workbench.action.setLayoutDensity.${ModernUIDensity.Default}`, ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI_DENSITY}`, ModernUIDensity.Default), localize('layoutDensityDefault', "Default"), layoutDensityDefaultIcon), + CreateOptionLayoutItem(`workbench.action.setLayoutDensity.${ModernUIDensity.Compact}`, ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI_DENSITY}`, ModernUIDensity.Compact), localize('layoutDensityCompact', "Compact"), layoutDensityCompactIcon), +]; + const MiscLayoutOptions: CustomizeLayoutItem[] = [ CreateOptionLayoutItem('workbench.action.toggleFullScreen', IsMainWindowFullscreenContext, localize('fullscreen', "Full Screen"), fullscreenIcon), CreateOptionLayoutItem('workbench.action.toggleZenMode', InEditorZenModeContext, localize('zenMode', "Zen Mode"), zenModeIcon), @@ -1392,11 +1402,14 @@ const MiscLayoutOptions: CustomizeLayoutItem[] = [ ]; const LayoutContextKeySet = new Set(); -for (const { active } of [...ToggleVisibilityActions, ...MoveSideBarActions, ...AlignPanelActions, ...QuickInputActions, ...MiscLayoutOptions]) { +for (const { active } of [...ToggleVisibilityActions, ...MoveSideBarActions, ...AlignPanelActions, ...QuickInputActions, ...LayoutDensityActions, ...MiscLayoutOptions]) { for (const key of active.keys()) { LayoutContextKeySet.add(key); } } +for (const key of ModernUIEnabledContext.keys()) { + LayoutContextKeySet.add(key); +} /** * Matches the title bar's `editorActionsEnabled` getter: true when editor @@ -1484,6 +1497,17 @@ registerAction2(class CustomizeLayoutAction extends Action2 { ] }; }; + const layoutDensityItems: QuickPickItem[] = []; + if (ModernUIEnabledContext.evaluate(contextKeyService.getContext(null))) { + layoutDensityItems.push( + { + type: 'separator', + label: localize('layoutDensity', "Layout Density") + }, + ...LayoutDensityActions.map(toQuickPickItem) + ); + } + return [ { type: 'separator', @@ -1505,6 +1529,7 @@ registerAction2(class CustomizeLayoutAction extends Action2 { label: localize('quickOpen', "Quick Input Position") }, ...QuickInputActions.map(toQuickPickItem), + ...layoutDensityItems, { type: 'separator', label: localize('layoutModes', "Modes"), @@ -1593,6 +1618,7 @@ registerAction2(class CustomizeLayoutAction extends Action2 { resetSetting('workbench.sideBar.location'); resetSetting('workbench.statusBar.visible'); resetSetting('workbench.panel.defaultLocation'); + resetSetting(LayoutSettings.MODERN_UI_DENSITY); if (!isMacintosh || !isNative) { resetSetting('window.menuBarVisibility'); From 04a72f856fe0664c01c3f37911012ea389fc1f30 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:57:53 +0200 Subject: [PATCH 16/44] agentHost: Clarify Codex agent description (#334325) --- .../contrib/chat/browser/agentSessions/agentSessions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 1ede7615d60582..233c5c84fa2875 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -181,7 +181,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionTarget) case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Open a new Codex session using the Codex extension from OpenAI. Codex sessions can be managed from the chat sessions view."); case AgentSessionProviders.AgentHostCodex: - return localize('chat.session.providerDescription.agentHostCodex', "Delegate tasks to the Codex App Server using the Codex models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); + return localize('chat.session.providerDescription.agentHostCodex', "Delegate tasks to the Codex agent using models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); case AgentSessionProviders.Growth: return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); case AgentSessionProviders.AgentHostCopilot: From 8918ad7a6836ac23349a56084a21caefa1cabec7 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 3 Sep 2026 19:01:34 +0100 Subject: [PATCH 17/44] workbench: address compact activity menu feedback Include the leading inset in the menubar's measured height, use the standard codicon size, and cover shared focus/open styles in normal and high-contrast themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/floatingPanels.css | 13 +-- .../modernUI/browser/media/activityBar.css | 7 +- .../browser/modernUI.contribution.test.ts | 80 +++++++++++++++++++ 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 9b6f080c7a55b7..20d856e1078b23 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -318,12 +318,15 @@ /* * Inset the first and last item from the card's ends by the same amount as the sides. The * horizontal inset is half the lane, of which the card's border already contributes one - * stroke, so the remainder is what these margins have to supply. When the compact menu is - * present it forms the first cluster, with the composite bar retaining the same inter-cluster - * spacing. Overrides the fixed values in `modernUI/browser/media/padding.css`, which predate - * the lane. + * stroke. The compact menu reserves its inset as padding so its measured height includes it; + * the composite bar uses the same value as its leading margin. Overrides the fixed values in + * `modernUI/browser/media/padding.css`, which predate the lane. */ -.monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .menubar.compact, +.monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .menubar.compact { + height: calc(var(--activity-bar-action-height, 36px) + (var(--modern-ui-activitybar-lane) - 2px) / 2); + padding-top: calc((var(--modern-ui-activitybar-lane) - 2px) / 2); +} + .monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .composite-bar { margin-top: calc(var(--modern-ui-activitybar-lane) / 2 - var(--vscode-strokeThickness)); } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css index 7827127cd28f07..9b9f5846634a78 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css @@ -64,7 +64,7 @@ justify-content: center; } -.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:focus { +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:is(:focus, .open) { background-color: transparent; } @@ -74,16 +74,15 @@ height: calc(var(--activity-bar-action-height, 36px) - var(--vscode-spacing-size40)); padding: 0; border-radius: var(--vscode-cornerRadius-small); - font-size: var(--activity-bar-icon-size, var(--vscode-codiconFontSize)); + font-size: var(--vscode-codiconFontSize); } -.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:focus:not(.open) .toolbar-toggle-more, .modern-ui.monaco-workbench .activitybar .menubar.compact:not(:focus-within) > .menubar-menu-button:hover .toolbar-toggle-more { color: var(--vscode-modernActivityBarItem-hoverForeground); background-color: var(--vscode-modernActivityBarItem-hoverBackground); } -.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button.open .toolbar-toggle-more { +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:is(:focus, .open) .toolbar-toggle-more { color: var(--vscode-modernActivityBarItem-activeForeground); background-color: var(--vscode-modernActivityBarItem-activeBackground); } 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 97d5cc40364f83..887fb07cd8430a 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 @@ -35,6 +35,7 @@ import '../../../../browser/media/floatingPanels.css'; import '../../../../../base/browser/ui/menu/menubar.css'; import '../../../../browser/parts/activitybar/media/activityaction.css'; import '../../../../browser/parts/media/paneCompositePart.css'; +import '../../../../browser/parts/titlebar/media/menubarControl.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; import '../../../../browser/parts/notifications/media/notificationsCenter.css'; import '../../../../browser/parts/notifications/media/notificationsToasts.css'; @@ -969,7 +970,9 @@ suite('ModernUIContribution', () => { test('keeps floating rail overlays anchored to the viewport', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui floating-panels'; + root.style.height = '200px'; root.style.display = 'inline-flex'; + root.style.setProperty('--activity-bar-action-height', '36px'); root.style.setProperty('--activity-bar-width', '36px'); root.style.setProperty('--vscode-spacing-sizeNone', '0px'); root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -991,15 +994,24 @@ suite('ModernUIContribution', () => { const activityBarBounds = activityBar.getBoundingClientRect(); const menuBounds = menu.getBoundingClientRect(); + const menuBarStyle = getWindow(menubar).getComputedStyle(menubar); assert.deepStrictEqual({ position: getWindow(menu).getComputedStyle(menu).position, + menuBarHeight: menubar.clientHeight, + menuBarComputedHeight: menuBarStyle.height, + menuBarDisplay: menuBarStyle.display, + menuBarPaddingTop: menuBarStyle.paddingTop, top: menuBounds.top, left: menuBounds.left, width: menuBounds.width, leavesRail: menuBounds.left > activityBarBounds.right, }, { position: 'fixed', + menuBarHeight: 39, + menuBarComputedHeight: '39px', + menuBarDisplay: 'flex', + menuBarPaddingTop: '3px', top: 120, left: 240, width: 200, @@ -1007,6 +1019,74 @@ suite('ModernUIContribution', () => { }); }); + test('styles focused and open compact application menu states', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui floating-panels'; + root.style.setProperty('--activity-bar-action-height', '36px'); + root.style.setProperty('--vscode-codiconFontSize', '16px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-menubar-selectionBorder', '#123456'); + root.style.setProperty('--vscode-modernActivityBarItem-hoverBackground', '#234567'); + root.style.setProperty('--vscode-modernActivityBarItem-hoverForeground', '#345678'); + root.style.setProperty('--vscode-modernActivityBarItem-activeBackground', '#456789'); + root.style.setProperty('--vscode-modernActivityBarItem-activeForeground', '#56789a'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size80', '8px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const activityBar = appendElement(root, 'part activitybar left'); + const content = appendElement(activityBar, 'content'); + const menubar = appendElement(content, 'menubar compact'); + const menuButton = appendElement(menubar, 'menubar-menu-button'); + menuButton.tabIndex = 0; + const target = appendElement(menuButton, 'menubar-menu-title toolbar-toggle-more'); + const targetWindow = getWindow(target); + const targetStyles = () => { + const style = targetWindow.getComputedStyle(target); + return { + buttonBackgroundColor: targetWindow.getComputedStyle(menuButton).backgroundColor, + backgroundColor: style.backgroundColor, + color: style.color, + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }; + + // The headless runner cannot activate :focus; .open exercises the shared focus/open selectors. + menuButton.classList.add('open'); + const focusAndOpen = targetStyles(); + root.classList.add('hc-black'); + root.style.setProperty('--vscode-menubar-selectionBorder', '#abcdef'); + const highContrastFocusAndOpen = targetStyles(); + + assert.deepStrictEqual({ + fontSize: targetWindow.getComputedStyle(target).fontSize, + focusAndOpen, + highContrastFocusAndOpen, + }, { + fontSize: '16px', + focusAndOpen: { + buttonBackgroundColor: 'rgba(0, 0, 0, 0)', + backgroundColor: 'rgb(69, 103, 137)', + color: 'rgb(86, 120, 154)', + outlineColor: 'rgb(18, 52, 86)', + outlineStyle: 'solid', + outlineWidth: '1px', + }, + highContrastFocusAndOpen: { + buttonBackgroundColor: 'rgba(0, 0, 0, 0)', + backgroundColor: 'rgb(69, 103, 137)', + color: 'rgb(86, 120, 154)', + outlineColor: 'rgb(171, 205, 239)', + outlineStyle: 'solid', + outlineWidth: '1px', + }, + }); + }); + test('uses the editor surface border color', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui floating-panels'; From 7a7037f497cfe6c8218cd1188fb52dabb6bfd61e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 14:02:31 -0400 Subject: [PATCH 18/44] chat: Fix pasted GitHub reference interactions (#334310) --- .../contrib/chat/browser/media/chatInput.css | 4 ++ .../test/browser/newChatInputPaste.test.ts | 8 +-- .../attachments/chatDynamicVariables.ts | 7 +++ .../chatMarkdownDecorationsRenderer.ts | 16 +++++ .../chat/browser/widget/chatListRenderer.ts | 11 ++-- .../editor/chatInputReferenceDecorations.ts | 22 ++++--- .../widget/input/editor/chatPasteProviders.ts | 4 +- .../chat/common/attachments/chatVariables.ts | 2 + .../browser/attachments/chatVariables.test.ts | 55 ++++++++++++++++- .../browser/widget/chatListRenderer.test.ts | 60 ++++++++++++++++++- 10 files changed, 166 insertions(+), 23 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 1e83b178f923ce..e76a8b47a69b8e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -145,6 +145,10 @@ background-color: var(--vscode-agentsChatInput-background) !important; } +.sessions-chat-editor .monaco-editor .mtk1 { + color: var(--vscode-input-foreground); +} + /* Inline decoration highlights for the new-session input editor: slash commands, * onboarding prompt placeholders, `#file:` variable references, and agent-host * completion references. */ diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts index ee0165d19cd72d..2177a38e162ff7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts @@ -16,7 +16,6 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { DocumentPasteTriggerKind, ICustomEdit } from '../../../../../editor/common/languages.js'; -import { TrackedRangeStickiness } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { TestCodeEditorService } from '../../../../../editor/test/browser/editorTestServices.js'; @@ -26,8 +25,9 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { ChatDynamicVariableModel, dynamicVariableDecorationType } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; +import { ChatDynamicVariableModel } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { registerChatInputReferenceDecorationType } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { PasteTextProvider, pastedTextArtifactDefaultMinLength } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { IChatRequestVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; @@ -65,9 +65,7 @@ suite('NewChatInputPasteTarget', () => { const uri = URI.from({ scheme: Schemas.sessionsChatInput, path: 'paste-test' }); const textModel = store.add(createTextModel('', null, undefined, uri)); const codeEditorService = store.add(new TestCodeEditorService(new TestThemeService())); - store.add(codeEditorService.registerDecorationType('test', dynamicVariableDecorationType, { - rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - })); + store.add(registerChatInputReferenceDecorationType(codeEditorService)); const editor = store.add(createTestCodeEditor(textModel, { serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), })); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts index 09e6202c2d1499..270daa6ce33c50 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; @@ -21,10 +22,12 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/i import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IChatRequestVariableEntry, isImageVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestVariableValue, IDynamicVariable, toAttachedContextDynamicVariable } from '../../common/attachments/chatVariables.js'; +import { chatSlashCommandForeground } from '../../common/widget/chatColors.js'; import { IChatWidget } from '../chat.js'; import { IChatWidgetContrib } from '../widget/chatWidget.js'; export const dynamicVariableDecorationType = 'chat-dynamic-variable'; +export const dynamicVariableIconDecorationType = 'chat-dynamic-variable-icon'; const issueIconCharacter = '\ueb0c'; const pullRequestIconCharacter = '\uea64'; @@ -292,6 +295,9 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC const decorationIds = this.host.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, validVariables.map((r): IDecorationOptions => ({ range: r.range, hoverMessage: this.getHoverForReference(r), + }))); + this.host.inputEditor.setDecorationsByType('chat', dynamicVariableIconDecorationType, validVariables.map((r): IDecorationOptions => ({ + range: Range.fromPositions(Range.getStartPosition(r.range)), renderOptions: getReferenceIconRenderOptions(r), }))); @@ -360,6 +366,7 @@ function getReferenceIconRenderOptions(reference: IDynamicVariable): IDecoration : undefined; return contentText ? { before: { + color: themeColorFromId(chatSlashCommandForeground), contentText, fontFamily: 'codicon', margin: '0 2px 0 0', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index bd96da33f51615..3298219d724121 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -9,6 +9,7 @@ import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -25,6 +26,7 @@ import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameS import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../common/widget/chatColors.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, chatSubcommandLeader, IParsedChatRequest, IParsedChatRequestPart } from '../../../common/requestParser/chatParserTypes.js'; import { IChatMarkdownContent, IChatService } from '../../../common/chatService/chatService.js'; +import { chatPasteLinkMetadataKey } from '../../../common/attachments/chatVariables.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../chat.js'; @@ -81,6 +83,16 @@ export interface IDecorationWidgetArgs { title?: string; } +export function getPastedChatReferenceMarkdown(part: IParsedChatRequestPart, uri: URI | undefined): string | undefined { + if (!(part instanceof ChatRequestDynamicVariablePart) + || part._meta?.[chatPasteLinkMetadataKey] !== true + || (uri?.scheme !== Schemas.http && uri?.scheme !== Schemas.https)) { + return undefined; + } + + return `[${part.text}](${uri.toString(true)})`; +} + export class ChatMarkdownDecorationsRenderer extends Disposable { private readonly richLinkDecorator: Lazy; @@ -123,6 +135,10 @@ export class ChatMarkdownDecorationsRenderer extends Disposable { const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ? part.data : undefined; + const pastedLink = getPastedChatReferenceMarkdown(part, uri); + if (pastedLink) { + return pastedLink; + } const title = uri ? this.labelService.getUriLabel(uri, { relative: true }) : part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : part instanceof ChatRequestAgentSubcommandPart ? part.command.description : diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 6f05c72cc6a06d..9ee9bb29cd4c6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -663,6 +663,10 @@ function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation } } +export function isAnchorTarget(target: EventTarget | null): boolean { + return dom.isHTMLElement(target) && !!target.closest('a'); +} + export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -2137,7 +2141,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') !== 'none' && this.rendererOptions.editable) { templateData.elementDisposables.add(dom.addDisposableListener(templateData.rowContainer, dom.EventType.KEY_DOWN, e => { const ev = new StandardKeyboardEvent(e); - if (ev.equals(KeyCode.Space) || ev.equals(KeyCode.Enter)) { + if ((ev.equals(KeyCode.Space) || ev.equals(KeyCode.Enter)) && !isAnchorTarget(e.target)) { if (this.viewModel?.editing?.id !== element.id) { ev.preventDefault(); ev.stopPropagation(); @@ -4564,11 +4568,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { store.add(codeEditorService.registerDecorationType('test', dynamicVariableDecorationType, { rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, })); + store.add(codeEditorService.registerDecorationType('test', dynamicVariableIconDecorationType, { + color: { id: 'chat.slashCommandForeground' }, + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + })); const editor = store.add(createTestCodeEditor(textModel, { serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), })); @@ -279,6 +285,53 @@ suite('ChatDynamicVariableModel', () => { }); }); + test('renders GitHub reference icons with the reference foreground color', () => { + const issueText = 'microsoft/vscode#334284'; + const pullRequestText = 'microsoft/vscode#333953'; + const { editor, model } = createDynamicVariableModel(`${issueText} ${pullRequestText}`); + let iconDecorations: readonly IDecorationOptions[] = []; + const setDecorationsByType = editor.setDecorationsByType.bind(editor); + editor.setDecorationsByType = ((description: string, key: string, options: IDecorationOptions[]) => { + if (key === dynamicVariableIconDecorationType) { + iconDecorations = options; + } + return setDecorationsByType(description, key, options); + }) as typeof editor.setDecorationsByType; + + model.addReference(createMockVariable({ + range: new Range(1, 1, 1, issueText.length + 1), + icon: Codicon.issues, + })); + model.addReference(createMockVariable({ + id: 'var-2', + range: new Range(1, issueText.length + 2, 1, issueText.length + pullRequestText.length + 2), + icon: Codicon.gitPullRequest, + })); + + assert.deepStrictEqual(iconDecorations.map(decoration => ({ + range: decoration.range, + before: decoration.renderOptions?.before, + })), [{ + range: new Range(1, 1, 1, 1), + before: { + color: { id: 'chat.slashCommandForeground' }, + contentText: '\ueb0c', + fontFamily: 'codicon', + margin: '0 2px 0 0', + verticalAlign: 'middle', + }, + }, { + range: new Range(1, issueText.length + 2, 1, issueText.length + 2), + before: { + color: { id: 'chat.slashCommandForeground' }, + contentText: '\uea64', + fontFamily: 'codicon', + margin: '0 2px 0 0', + verticalAlign: 'middle', + }, + }]); + }); + test('removes a reference without deleting replacement text', () => { const { editor, model } = createDynamicVariableModel('explain #sym:example '); model.addReference(createMockVariable({ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 975acd3b071a77..57cf009b563ed3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -23,11 +23,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IViewDescriptorService } from '../../../../../common/views.js'; import { IChatOutputRendererService } from '../../../browser/chatOutputItemRenderer.js'; -import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithActiveSubagentContent, endsWithCompletedQuestionInteraction, formatCompletedResponseDisclosureLabel, formatResponseTokenStats, getCompletedResponseCollapseEndIndex, getFinalResponseStartIndex, getFinalResponseStartIndexAfterMovingResponseOutcomeTools, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isFinalResponseRendered, isWaitingForMcpServers, moveResponseOutcomeToolsAfterFinalResponse, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCollapseCompletedResponsePart, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithActiveSubagentContent, endsWithCompletedQuestionInteraction, formatCompletedResponseDisclosureLabel, formatResponseTokenStats, getCompletedResponseCollapseEndIndex, getFinalResponseStartIndex, getFinalResponseStartIndexAfterMovingResponseOutcomeTools, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isAnchorTarget, isFinalResponseRendered, isWaitingForMcpServers, moveResponseOutcomeToolsAfterFinalResponse, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCollapseCompletedResponsePart, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { ChatWidget } from '../../../browser/widget/chatWidget.js'; import { isChatTurnStatusPillsEnabled } from '../../../browser/widget/chatTurnPills.js'; import { ChatSubagentContentPart } from '../../../browser/widget/chatContentParts/chatSubagentContentPart.js'; import { ChatCollapsibleContentPart } from '../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; +import { getPastedChatReferenceMarkdown } from '../../../browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatRequestQueueKind, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; @@ -35,7 +36,7 @@ import { ChatModel } from '../../../common/model/chatModel.js'; import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; +import { ChatRequestDynamicVariablePart, ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { shouldRenderGeneratedImageResult, shouldRenderSessionCreatedResult } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; @@ -47,6 +48,61 @@ import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatMo suite('ChatListRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('recognizes nested compact reference content as a link target', () => { + const anchor = mainWindow.document.createElement('a'); + const icon = mainWindow.document.createElement('span'); + const label = mainWindow.document.createElement('span'); + anchor.append(icon, label); + + assert.deepStrictEqual({ + anchor: isAnchorTarget(anchor), + icon: isAnchorTarget(icon), + label: isAnchorTarget(label), + plainText: isAnchorTarget(mainWindow.document.createElement('span')), + textNode: isAnchorTarget(mainWindow.document.createTextNode('text')), + }, { + anchor: true, + icon: true, + label: true, + plainText: false, + textNode: false, + }); + }); + + test('renders pasted GitHub references as navigable links', () => { + const uri = URI.parse('https://github.com/microsoft/vscode/pull/334310'); + const part = new ChatRequestDynamicVariablePart( + new OffsetRange(0, 24), + new Range(1, 1, 1, 25), + 'microsoft/vscode#334310', + uri.toString(), + undefined, + uri, + undefined, + undefined, + undefined, + undefined, + { chatPasteLink: true }, + true, + uri.toString(), + ); + + assert.deepStrictEqual({ + pastedGitHubLink: getPastedChatReferenceMarkdown(part, uri), + ordinaryReference: getPastedChatReferenceMarkdown(new ChatRequestDynamicVariablePart( + part.range, + part.editorRange, + part.text, + part.id, + part.modelDescription, + part.data, + ), uri), + }, { + pastedGitHubLink: '[microsoft/vscode#334310](https://github.com/microsoft/vscode/pull/334310)', + ordinaryReference: undefined, + }); + }); + suite('shouldScheduleInitialHeightChange', () => { test('only schedules first measurement updates when needed to avoid clipping', () => { assert.deepStrictEqual([ From 3d7dfbdb447784fa685955cdcf8450b37881d8fc Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 14:12:50 -0400 Subject: [PATCH 19/44] sessions: Fix duplicate agent mode picker tab stop (#334308) --- .../agentHost/browser/agentHostModePicker.ts | 3 +- .../browser/agentHostSessionConfigPicker.ts | 35 +++++++++++++++++-- .../agentHostSessionConfigPicker.test.ts | 24 +++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index 8a2b09fd85f3a8..139d4f861d2eb7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -74,7 +74,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { this._watchProviders(this._sessionsProvidersService.getProviders()); } - render(container: HTMLElement): void { + render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); this._containerElement = container; @@ -104,6 +104,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { })); this._updateTrigger(); + return trigger; } private _watchProviders(providers: readonly ISessionsProvider[]): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 194dd3ab455e3c..bee227e2999fe8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -1227,12 +1227,13 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { } interface IConfigPickerWidget extends IDisposable { - render(container: HTMLElement): void; + render(container: HTMLElement): HTMLElement | void; showPicker?(anchor: HTMLElement, onHide?: () => void): boolean | void; } export class PickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { private _compact = false; + private _focusableElement: HTMLElement | undefined; constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); @@ -1243,10 +1244,40 @@ export class PickerActionViewItem extends BaseActionViewItem implements IChatInp override render(container: HTMLElement): void { this.element = container; - this._picker.render(container); + this._focusableElement = this._picker.render(container) ?? undefined; container.classList.toggle('compact-picker', this._compact); } + override focus(): void { + if (this._focusableElement) { + this._focusableElement.focus(); + } else { + super.focus(); + } + } + + override isFocused(): boolean { + return this._focusableElement + ? this._focusableElement === dom.getActiveElement() + : super.isFocused(); + } + + override blur(): void { + if (this._focusableElement) { + this._focusableElement.blur(); + } else { + super.blur(); + } + } + + override setFocusable(focusable: boolean): void { + if (this._focusableElement) { + this.element?.removeAttribute('tabindex'); + } else { + super.setFocusable(focusable); + } + } + isCompact(): boolean { return this._compact; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 0384d32808dfc3..5037fadd3b1b23 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -354,7 +354,13 @@ suite('Agent Host Session Config Picker', () => { test('picker action view items expose responsive compact state', () => { let pickerAnchor: HTMLElement | undefined; const item = store.add(new PickerActionViewItem({ - render: () => { }, + render: container => { + const trigger = document.createElement('a'); + trigger.classList.add('action-label'); + trigger.tabIndex = 0; + container.appendChild(trigger); + return trigger; + }, showPicker: anchor => { pickerAnchor = anchor; return true; @@ -362,6 +368,8 @@ suite('Agent Host Session Config Picker', () => { dispose: () => { }, })); const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); const overflowAnchor = document.createElement('button'); item.render(container); const expanded = { @@ -370,16 +378,28 @@ suite('Agent Host Session Config Picker', () => { }; item.setCompact(true); + item.setFocusable(true); + item.focus(); item.show(overflowAnchor); const compact = { compact: item.isCompact(), className: container.classList.contains('compact-picker'), usesOverflowAnchor: pickerAnchor === overflowAnchor, + wrapperTabIndex: container.tabIndex, + tabbableDescendants: container.querySelectorAll('[tabindex="0"]').length, + triggerFocused: item.isFocused(), }; assert.deepStrictEqual({ expanded, compact }, { expanded: { compact: false, className: false }, - compact: { compact: true, className: true, usesOverflowAnchor: true }, + compact: { + compact: true, + className: true, + usesOverflowAnchor: true, + wrapperTabIndex: -1, + tabbableDescendants: 1, + triggerFocused: true, + }, }); }); From 7bcc021b2d7f6cc260af2bec5e2e267ae2d600a1 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 20:25:42 +0200 Subject: [PATCH 20/44] Fix remote host empty state fixture width Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce266339-31ab-4cfb-a049-5b65fea0c7c8 --- .../test/browser/remoteHostUnavailableEmptyState.fixture.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts index 00aa9d3efa5728..93198287022c54 100644 --- a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -5,6 +5,7 @@ import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from '../../browser/parts/remoteHostUnavailableEmptyState.js'; +import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/' }, { HostNotRunning: defineComponentFixture({ @@ -47,7 +48,8 @@ export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/ function renderUnavailableState({ container, disposableStore }: ComponentFixtureContext, content: IRemoteHostUnavailableEmptyStateContent): void { container.style.position = 'relative'; - container.style.width = 'var(--session-view-centered-content-max-width)'; + container.style.width = `${AGENTS_CENTERED_CONTENT_MAX_WIDTH}px`; + container.style.setProperty('--session-view-centered-content-max-width', container.style.width); container.style.height = 'calc(var(--vscode-spacing-size400) * 6)'; container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; From 1e1ee361e263c95c283dddceae3a7bd3373590ee Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:27:23 -0700 Subject: [PATCH 21/44] Chat: unify Agent Host status pills across chat surfaces (#332982) * Agent Host changes for agents/editor-chat-panel-pills-enhancement * chat: scope pill resize observer to host window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: collapse status pills only on overflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: address pill review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: include subagent browsers in status pills Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: keep session input pills Agent Host-only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * agentHost: share session resolution policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: unify session input pills Route Agents Window and Agent Host editor/panel pills through one shared controller and canonical source builder. Remove the obsolete SessionHeaderMeta pill implementation, preserve live GitHub state through shared presentation models, and stabilize dropdown, focus, compact-layout, and changeset lifecycles to avoid flicker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * chat: fix session pill fixture services Allow production chat widget fixtures to register feature-specific services, and reuse one session-pill service bundle for isolated and full-chat fixtures. This restores the strict Component Explorer renders without duplicating mocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 * bring back rich pill hovers --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: BeniBenj Copilot-Session: 44dd6d62-f5b5-4039-8c99-7cd70e43d9a3 --- src/vs/base/browser/ui/tree/abstractTree.ts | 1 + .../browser/agentHostConnectionsService.ts | 48 +- .../common/agentHostConnectionsService.ts | 40 +- .../platform/agentHost/common/changesetUri.ts | 11 + .../agentHostConnectionsService.test.ts | 41 ++ .../test/common/changesetUri.test.ts | 17 + src/vs/sessions/LAYOUT.md | 2 + src/vs/sessions/browser/menus.ts | 2 - .../contrib/changes/browser/changesActions.ts | 146 +---- .../test/browser/changesActions.test.ts | 31 - .../sessions/contrib/chat/browser/chatView.ts | 2 +- .../browser/media/sessionChatInputToolbar.css | 67 -- .../contrib/chat/browser/sessionArtifacts.ts | 19 - .../sessionBackgroundActivitiesControl.ts | 10 - .../chat/browser/sessionBrowsersControl.ts | 14 +- .../chat/browser/sessionChatInputToolbar.ts | 513 +++++++-------- .../chat/browser/sessionCustomizations.ts | 15 - .../chat/browser/sessionMetadataPills.ts | 76 --- .../browser/sessionsChatAccessibilityHelp.ts | 4 +- .../browser/sessionBrowsersControl.test.ts | 17 +- .../browser/sessionChatInputToolbar.test.ts | 199 +++++- .../contrib/github/browser/issueActions.ts | 318 +-------- .../contrib/github/browser/issueHover.ts | 8 +- .../github/browser/pullRequestActions.ts | 399 +----------- .../github/browser/pullRequestHover.ts | 12 +- .../github/browser/pullRequestIconStatus.ts | 50 +- .../sessions/contrib/github/common/types.ts | 45 +- .../githubReferenceActionViewItems.test.ts | 205 ------ .../test/browser/pullRequestActions.test.ts | 115 +++- .../browser/agentHostSessionChangesets.ts | 13 +- .../browser/openAgentHostStateFile.test.ts | 2 +- .../cloudSandboxAgentHostContribution.ts | 5 +- .../remoteAgentHostSessionsProvider.ts | 19 +- .../remoteAgentHostSessionsProvider.test.ts | 32 +- .../services/sessions/common/session.ts | 30 +- src/vs/workbench/browser/chatDropdownPill.ts | 155 ++++- src/vs/workbench/browser/chatPills.ts | 283 +++++++- src/vs/workbench/browser/chatResourcePill.ts | 9 + src/vs/workbench/browser/media/chatPills.css | 94 +++ src/vs/workbench/common/chatPullRequest.ts | 69 ++ .../browser/actions/chatAccessibilityHelp.ts | 10 +- .../agentHostCustomizationService.ts | 8 +- .../agentHost/agentHostResponseFileChanges.ts | 119 ++-- .../agentHost/agentHostSessionInputPills.ts | 551 ++++++++++++++++ .../chat/browser/chat.shared.contribution.ts | 4 +- src/vs/workbench/contrib/chat/browser/chat.ts | 2 + .../contrib/chat/browser/chatInputPills.ts | 292 +++++++++ .../browser/chatResponseFileChangesService.ts | 7 +- .../editorChatResponseFileChangesService.ts | 30 +- .../chat/browser/sessionChatPillOptions.ts | 89 +++ .../chatChangesSummaryPart.ts | 12 +- .../chatContentParts/chatTurnPillsPart.ts | 6 +- .../chat/browser/widget/chatListWidget.ts | 15 +- .../contrib/chat/browser/widget/chatWidget.ts | 33 +- .../browser/widgetHosts/editor/chatEditor.ts | 2 + .../widgetHosts/viewPane/chatViewPane.ts | 2 + .../chat/common/editing/chatEditingService.ts | 3 + .../contrib/chat/common/sessionChatPills.ts | 37 +- .../chatAccessibilityHelp.test.ts | 20 + .../agentHostResponseFileChanges.test.ts | 183 +++++- .../agentHostSessionInputPills.test.ts | 611 ++++++++++++++++++ .../chat/test/browser/chatInputPills.test.ts | 134 ++++ .../chatResponseFileChangesService.test.ts | 21 +- .../chatChangesSummaryPart.test.ts | 72 +++ .../chatTurnPillsPart.test.ts | 7 +- .../browser/widget/chatListWidget.test.ts | 10 +- .../test/browser/widget/chatTurnPills.test.ts | 18 +- .../chat/test/common/sessionChatPills.test.ts | 24 +- .../workbench/test/browser/chatPills.test.ts | 482 ++++++++++++++ .../chat/chatFixtureUtils.ts | 2 + .../chat/chatWidget.fixture.ts | 5 +- .../sessions/openIssue.fixture.ts | 255 -------- .../sessions/openPullRequest.fixture.ts | 271 -------- .../sessionChatInputToolbar.fixture.ts | 160 ++++- .../sessions/viewAllChanges.fixture.ts | 147 ----- 75 files changed, 4239 insertions(+), 2543 deletions(-) delete mode 100644 src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts delete mode 100644 src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts create mode 100644 src/vs/workbench/common/chatPullRequest.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatInputPills.ts create mode 100644 src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts rename src/vs/{sessions => workbench}/contrib/chat/common/sessionChatPills.ts (81%) create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts rename src/vs/{sessions => workbench}/contrib/chat/test/common/sessionChatPills.test.ts (87%) create mode 100644 src/vs/workbench/test/browser/chatPills.test.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 33bf50cd90bc45..382d3834a288ad 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -2354,6 +2354,7 @@ function asTreeContextMenuEvent(event: IListContextMenuEv export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { readonly defaultIndent?: number; // Only recommended for compact layouts. Leave unchanged otherwise + readonly paddingBottom?: number; readonly multipleSelectionSupport?: boolean; readonly typeNavigationEnabled?: boolean; readonly typeNavigationMode?: TypeNavigationMode; diff --git a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts index 90fe66926b0fee..757cdd288e2b0e 100644 --- a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { AgentSession } from '../common/agent.js'; import { IAgentConnection, IAgentHostService } from '../common/agentService.js'; -import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, remoteAgentHostSessionTypeAuthorityPrefix } from '../common/agentHostSessionType.js'; import { agentHostAuthority } from '../common/agentHostUri.js'; import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; @@ -26,6 +26,9 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos private readonly _onDidChangeConnections = this._register(new Emitter()); readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + private readonly _onDidChangeSessionResolution = this._register(new Emitter()); + readonly onDidChangeSessionResolution: Event = this._onDidChangeSessionResolution.event; + private readonly _sessionResolutionPolicies = new Map(); constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -33,10 +36,15 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos ) { super(); - this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._onDidChangeConnections.fire())); + this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._fireConnectionsChanged())); // Ambient (re)start/exit changes whether the ambient connection is ready. - this._register(this._agentHostService.onAgentHostStart(() => this._onDidChangeConnections.fire())); - this._register(this._agentHostService.onAgentHostExit(() => this._onDidChangeConnections.fire())); + this._register(this._agentHostService.onAgentHostStart(() => this._fireConnectionsChanged())); + this._register(this._agentHostService.onAgentHostExit(() => this._fireConnectionsChanged())); + } + + private _fireConnectionsChanged(): void { + this._onDidChangeConnections.fire(); + this._onDidChangeSessionResolution.fire(); } get ambientConnection(): IAgentConnection { @@ -76,6 +84,20 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos return this._remoteAgentHostService.getConnection(address); } + registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable { + if (this._sessionResolutionPolicies.has(authority)) { + throw new Error(`Agent Host session resolution policy already registered for '${authority}'`); + } + this._sessionResolutionPolicies.set(authority, policy); + this._onDidChangeSessionResolution.fire(); + return toDisposable(() => { + if (this._sessionResolutionPolicies.get(authority) === policy) { + this._sessionResolutionPolicies.delete(authority); + this._onDidChangeSessionResolution.fire(); + } + }); + } + resolveSessionResource(sessionResource: URI): IAgentHostSessionResolution | undefined { const scheme = sessionResource.scheme; const rawSessionId = sessionResource.path.substring(1); @@ -83,7 +105,7 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos if (scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) { const provider = scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length); return provider - ? { connection: this._agentHostService, backendSession: AgentSession.uri(provider, rawSessionId) } + ? this._createSessionResolution(AMBIENT_AGENT_HOST_AUTHORITY, this._agentHostService, provider, rawSessionId) : undefined; } @@ -96,13 +118,25 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos const provider = scheme.substring(remoteAgentHostSessionTypeAuthorityPrefix(authority).length); const connection = this.getConnectionByAuthority(authority); if (provider && connection) { - return { connection, backendSession: AgentSession.uri(provider, rawSessionId) }; + return this._createSessionResolution(authority, connection, provider, rawSessionId); } } } return undefined; } + + private _createSessionResolution(authority: string, connection: IAgentConnection, provider: string, rawSessionId: string): IAgentHostSessionResolution { + const policy = this._sessionResolutionPolicies.get(authority); + const alias = policy?.sessionSchemeAlias; + const backendProvider = alias?.ui === provider ? alias.backend : provider; + return { + connection, + connectionAuthority: authority, + backendSession: AgentSession.uri(backendProvider, rawSessionId), + defaultChangesetKind: policy?.defaultChangesetKind, + }; + } } registerSingleton(IAgentHostConnectionsService, AgentHostConnectionsService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts index 0de98617beadd7..8fc06755e59998 100644 --- a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import type { IDisposable } from '../../../base/common/lifecycle.js'; import type { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentConnection } from './agentService.js'; +import type { DefaultChangesetKind } from './changesetUri.js'; /** * Chat-session resource scheme prefix for the window's ambient/local agent @@ -57,28 +59,43 @@ export interface IAgentHostConnectionInfo { /** * The result of resolving a chat-session resource to its backing agent host: - * the owning {@link IAgentConnection} and the canonical backend agent-session - * URI (`:/`) used for protocol operations on that connection. + * the owning {@link IAgentConnection}, its authority, and the canonical backend + * agent-session URI used for protocol operations on that connection. */ export interface IAgentHostSessionResolution { readonly connection: IAgentConnection; + readonly connectionAuthority: string; readonly backendSession: URI; + readonly defaultChangesetKind?: DefaultChangesetKind; +} + +/** Provider-owned policy needed to resolve a workbench session resource back to its host. */ +export interface IAgentHostSessionResolutionPolicy { + readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; + readonly defaultChangesetKind?: DefaultChangesetKind; +} + +/** The UI and backend schemes for a session whose provider and host identities differ. */ +export interface IAgentHostSessionSchemeAlias { + readonly ui: string; + readonly backend: string; } export const IAgentHostConnectionsService = createDecorator('agentHostConnectionsService'); /** - * A thin, read-only facade over the window's ambient agent host + * A facade over the window's ambient agent host * (`IAgentHostService`) and the registry of remote agent hosts * (`IRemoteAgentHostService`), so consumers can enumerate and resolve * {@link IAgentConnection}s without branching on local-vs-remote or - * fanning out over "1 ambient + N remote" themselves. + * fanning out over "1 ambient + N remote" themselves. Session providers may + * register declarative scheme/default-changeset policy so every consumer + * resolves provider-specific session resources consistently. * - * This service deliberately does NOT expose lifecycle/management operations: + * This service deliberately does NOT expose connection lifecycle operations: * ambient-process concerns (restart, inspect, auth-pending) stay on * `IAgentHostService`, and remote-registry mutations (add/remove/reconnect/ - * upgrade) stay on `IRemoteAgentHostService`. This facade only answers - * "which connections exist?" and "give me the connection for X". + * upgrade) stay on `IRemoteAgentHostService`. */ export interface IAgentHostConnectionsService { readonly _serviceBrand: undefined; @@ -86,6 +103,9 @@ export interface IAgentHostConnectionsService { /** Fires when the set of connections changes (ambient lifecycle or remotes added/removed). */ readonly onDidChangeConnections: Event; + /** Fires when connection or provider policy changes can alter session-resource resolution. */ + readonly onDidChangeSessionResolution: Event; + /** * All known connections as `[ambient, ...remotes]`. The ambient entry is * always present with a live `connection`; only remote entries may have @@ -111,6 +131,12 @@ export interface IAgentHostConnectionsService { */ getConnectionByAddress(address: string): IAgentConnection | undefined; + /** + * Registers provider-owned session resolution policy for a connection. + * At most one policy may be registered for an authority. + */ + registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable; + /** * Resolves an agent-host chat-session resource to its owning connection and * backend session URI. Handles both local schemes diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 582992adfe0c9e..4eb642465a9ffc 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -129,6 +129,17 @@ export const enum ChangesetKind { Unknown = 'unknown', } +/** Changeset kinds that can represent a session's default changes view. */ +export type DefaultChangesetKind = ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; + +/** Selects the configured default changeset, falling back to the first catalogue entry. */ +export function selectDefaultChangeset>( + changesets: readonly T[] | undefined, + defaultKind: DefaultChangesetKind = ChangesetKind.Branch, +): T | undefined { + return changesets?.find(changeset => changeset.changeKind === defaultKind) ?? changesets?.[0]; +} + /** RFC 3986 scheme prefix, e.g. the `ahp-session:` in `ahp-session:/abc`. */ const URI_SCHEME_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.\-]*:/; diff --git a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts index c887b90cffc0e9..b05643e25fbcb9 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts @@ -10,6 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentHostConnectionsService } from '../../browser/agentHostConnectionsService.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../../common/agentHostConnectionsService.js'; import type { IAgentConnection, IAgentHostService } from '../../common/agentService.js'; +import { ChangesetKind } from '../../common/changesetUri.js'; import type { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService } from '../../common/remoteAgentHostService.js'; /** A connection stand-in identified by a `marker` so equality checks read clearly. */ @@ -98,14 +99,54 @@ suite('AgentHostConnectionsService', () => { const local = service.resolveSessionResource(URI.parse('agent-host-copilotcli:/abc123')); assert.strictEqual(local?.connection, ambient); + assert.strictEqual(local?.connectionAuthority, AMBIENT_AGENT_HOST_AUTHORITY); assert.strictEqual(local?.backendSession.toString(), 'copilotcli:/abc123'); const remote = service.resolveSessionResource(URI.parse('remote-myhost-copilotcli:/xyz789')); assert.strictEqual(remote?.connection, remoteConn); + assert.strictEqual(remote?.connectionAuthority, 'myhost'); assert.strictEqual(remote?.backendSession.toString(), 'copilotcli:/xyz789'); // Non-agent-host scheme and unknown remote authority resolve to undefined. assert.strictEqual(service.resolveSessionResource(URI.parse('vscode-chat-editor:/foo')), undefined); assert.strictEqual(service.resolveSessionResource(URI.parse('remote-unknown-copilotcli:/foo')), undefined); }); + + test('applies provider session resolution policy', () => { + const remoteConn = fakeConnection('remote-host'); + const byAddress = new Map([['myhost', remoteConn]]); + const { service } = createService([info('myhost', 'My Remote')], byAddress); + let resolutionChanges = 0; + store.add(service.onDidChangeSessionResolution(() => resolutionChanges++)); + + const registration = store.add(service.registerSessionResolutionPolicy('myhost', { + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + })); + const mapped = service.resolveSessionResource(URI.parse('remote-myhost-copilot:/xyz789')); + registration.dispose(); + const restored = service.resolveSessionResource(URI.parse('remote-myhost-copilot:/xyz789')); + + assert.deepStrictEqual({ + mapped: { + backendSession: mapped?.backendSession.toString(), + defaultChangesetKind: mapped?.defaultChangesetKind, + }, + restored: { + backendSession: restored?.backendSession.toString(), + defaultChangesetKind: restored?.defaultChangesetKind, + }, + resolutionChanges, + }, { + mapped: { + backendSession: 'ahp-session:/xyz789', + defaultChangesetKind: ChangesetKind.Session, + }, + restored: { + backendSession: 'copilot:/xyz789', + defaultChangesetKind: undefined, + }, + resolutionChanges: 2, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/common/changesetUri.test.ts b/src/vs/platform/agentHost/test/common/changesetUri.test.ts index 342c58f1cf4bf1..b16ced59c62721 100644 --- a/src/vs/platform/agentHost/test/common/changesetUri.test.ts +++ b/src/vs/platform/agentHost/test/common/changesetUri.test.ts @@ -27,6 +27,7 @@ import { parseCompareTurnsChangesetUri, parseTurnChangesetUri, resolveChangesetUriTemplate, + selectDefaultChangeset, } from '../../common/changesetUri.js'; suite('changesetUri', () => { @@ -151,6 +152,22 @@ suite('changesetUri', () => { assert.strictEqual(resolveChangesetUriTemplate(`${sessionUri}/`, 'changeset/branch'), `${sessionUri}/changeset/branch`); }); + test('selectDefaultChangeset follows the configured kind and falls back to catalogue order', () => { + const changesets = [ + { label: 'Session', changeKind: ChangesetKind.Session }, + { label: 'Branch', changeKind: ChangesetKind.Branch }, + ]; + assert.deepStrictEqual({ + implicit: selectDefaultChangeset(changesets)?.label, + explicit: selectDefaultChangeset(changesets, ChangesetKind.Session)?.label, + missing: selectDefaultChangeset(changesets, ChangesetKind.Uncommitted)?.label, + }, { + implicit: 'Branch', + explicit: 'Session', + missing: 'Session', + }); + }); + test('predicates match the parser semantics', () => { assert.strictEqual(isChangesetUri(buildSessionChangesetUri(sessionUri)), true); assert.strictEqual(isChangesetUri(buildUncommittedChangesetUri(sessionUri)), true); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 751fbad59c12c8..73be8c56f1b344 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -76,6 +76,8 @@ The durable state and transition catalog lives in [SINGLE_PANE_SCENARIOS.md](SIN Editors must be opened through `IEditorService`. Sessions-specific presentation must not bypass editor service behavior by opening directly on an editor group. +Chat input status-pill composition is owned by the shared workbench `ChatInputPills` and `StandardChatInputPillSources` components. The Agents Window and Agent Host editor/panel surfaces supply observable data adapters and their allowed pill kinds only; ordering, per-kind presentation, visibility, context menus, keyboard behavior, compact layout, and lifecycle rendering must not be reimplemented per surface. + Session providers register internal per-session directories as resource label homes. URI labels render as `/`, and breadcrumbs render the same home label as their root segment. Without a matching home formatter, existing URI-label and breadcrumb behavior is unchanged. ## Custom views diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index cd278d7e84e11a..5cf11c63ce30d7 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -62,8 +62,6 @@ export const Menus = { SessionsEditorTitle: new MenuId('SessionsEditorTitle'), SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionsEditorTabsBarAddTab: new MenuId('SessionsEditorTabsBarAddTab'), - SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), - /** * Entries merged into the dropdown of the changes button bar's primary * button. A submenu contributed to its `primary` group names a group of diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index e2ad345407b139..0c2234bb598f1f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -3,18 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../base/browser/dom.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, observableValue, transaction } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -28,10 +23,8 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; -import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasOpenPullRequestContext, SessionHasWorkspaceContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { SessionHasOpenPullRequestContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; @@ -53,19 +46,6 @@ class ViewAllChangesAction extends Action2 { title: localize2('agentSessions.changes', 'Changes'), icon: Codicon.diffMultiple, f1: false, - // Metadata pill rendered with live +/- counts, or the counts last shown - // for the session while it has not reported its changes yet. A session - // without a workspace folder (a quick chat) has no Changes editor to - // open, so the pill would be inert — it never joins the pill row. - menu: { - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and( - ContextKeyExpr.or(SessionHasChangesContext, SessionHasCachedChangesContext), - SessionHasWorkspaceContext - ) - }, }); } @@ -242,123 +222,6 @@ class CollapseUnchangedRegionsAction extends Action2 { } registerAction2(CollapseUnchangedRegionsAction); -// --- View All Changes action view item (session header diff stats) - -interface IDiffStats { - readonly files: number; - readonly insertions: number; - readonly deletions: number; - readonly branch: string | undefined; -} - -/** - * Renders the {@link ViewAllChangesAction} as a ` files +insertions -deletions` - * metadata pill. It appends the session's live aggregate diff stats. Activating the item runs the - * action, which opens the multi-file diff editor. - * - * The stats are read from the {@link ISessionContext} so the correct per-session changes - * are shown even when several session views are visible at once. The counts come from the - * session's {@link ISession.changesSummary} when available, falling back to aggregating the - * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's - * top-level {@link IActiveSession.changes} when none is default). - * - * A session reports its changes late, so until it reports any the counts last shown for it - * are taken from the {@link ISessionChangesStatsCache} — the pill is then already there, - * with plausible counts, the moment the session opens. - */ -export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { - - private readonly _diffStatsObs: IObservable; - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, - ) { - super(undefined, action, options); - - this._diffStatsObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); - - const stats = session - ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) - : undefined; - - return { - branch, - files: stats?.files ?? 0, - insertions: stats?.insertions ?? 0, - deletions: stats?.deletions ?? 0, - } satisfies IDiffStats; - }); - - this._register(autorun(reader => { - this._diffStatsObs.read(reader); - this.updateLabel(); - this.updateTooltip(); - this.updateAriaLabel(); - })); - } - - protected override getLabelText(): string { - const { files } = this._diffStatsObs.get(); - return files === 1 - ? localize('agentSessions.changes.file', "{0} file", files) - : localize('agentSessions.changes.files', "{0} files", files); - } - - protected override getAdditionalLabelContent(): Array { - const { insertions, deletions } = this._diffStatsObs.get(); - return [ - $('span.chat-pill-added', undefined, `+${insertions}`), - $('span.chat-pill-removed', undefined, `-${deletions}`), - ]; - } - - protected override getTooltip(): string { - const { branch } = this._diffStatsObs.get(); - return branch - ? localize('agentSessions.viewChanges.tooltip.branch', "View All Changes ({0})", branch) - : localize('agentSessions.viewChanges.tooltip', "View All Changes"); - } - - protected override getAriaLabel(): string { - const { files, insertions, deletions } = this._diffStatsObs.get(); - const filesLabel = files === 1 - ? localize('agentSessions.changes.file', "{0} file", files) - : localize('agentSessions.changes.files', "{0} files", files); - // e.g. "View All Changes (main): 3 files, +10, -4" - return localize('agentSessions.viewChanges.ariaLabel', "{0}: {1}, +{2}, -{3}", this.getTooltip(), filesLabel, insertions, deletions); - } -} - -/** - * Registers the {@link ViewAllChangesActionViewItem} for the diff-stats metadata pill. - */ -class ViewAllChangesActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.viewAllChangesActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, ViewAllChangesAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(ViewAllChangesActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - /** * Remembers the changes pill shown for each visible session so it can be rendered * optimistically the next time that session is opened, before the provider has @@ -588,5 +451,4 @@ export class NewSessionUncommittedChangesetOperationsActionContribution extends registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NewSessionUncommittedChangesetOperationsActionContribution.ID, NewSessionUncommittedChangesetOperationsActionContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index e1d4394b0c4ca9..15e082d42ffb51 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -16,46 +16,15 @@ import { Context } from '../../../../../platform/contextkey/browser/contextKeySe import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ActiveEditorContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; -import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../common/changes.js'; import { NewSessionUncommittedChangesetOperationsActionContribution } from '../../browser/changesActions.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; suite('Changes Actions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('changes pill stays out of the pill row for a session without a workspace folder', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionHeaderMeta) - .filter(isIMenuItem) - .find(item => item.command.id === VIEW_SESSION_CHANGES_COMMAND_ID); - - assert.ok(item, 'expected the changes pill on the session metadata menu'); - const evaluate = (state: { changes?: boolean; cachedChanges?: boolean; workspace?: boolean }) => { - const context = new Context(1, null); - context.setValue(SessionHasChangesContext.key, state.changes ?? false); - context.setValue(SessionHasCachedChangesContext.key, state.cachedChanges ?? false); - context.setValue(SessionHasWorkspaceContext.key, state.workspace ?? false); - return item.when?.evaluate(context) ?? false; - }; - - assert.deepStrictEqual({ - folderlessChatWithChanges: evaluate({ changes: true }), - folderlessChatWithCachedChanges: evaluate({ cachedChanges: true }), - workspaceSessionWithChanges: evaluate({ changes: true, workspace: true }), - workspaceSessionWithCachedChanges: evaluate({ cachedChanges: true, workspace: true }), - workspaceSessionWithoutChanges: evaluate({ workspace: true }), - }, { - folderlessChatWithChanges: false, - folderlessChatWithCachedChanges: false, - workspaceSessionWithChanges: true, - workspaceSessionWithCachedChanges: true, - workspaceSessionWithoutChanges: false, - }); - }); - test('draft session contributes uncommitted changeset operations to the editor header', async () => { const invokedOperations: string[] = []; const operations = observableValue('test.operations', [{ diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 0affeda1917ba1..6d2df37212794a 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -296,7 +296,7 @@ export class ChatView extends AbstractChatView { this._banners.setActive(this._isActive); // Floating status pills above the input. - this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar)); + this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar, false, () => this._widget.focusInput())); const updateChatPillsVisibility = (visible: boolean) => { this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); }; diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css deleted file mode 100644 index 7e6089f0fcdf36..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Horizontally scrollable status pills above the chat input. */ - -.session-chat-input-toolbar { - width: 100%; - min-width: 0; -} - -.session-chat-input-toolbar-content { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--vscode-spacing-size60); - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40) 0; -} - -.session-chat-input-toolbar-content > .chat-pills, -.session-chat-input-toolbar-content > .session-activity-pill { - flex-shrink: 0; -} - -.session-chat-input-toolbar-content > * { - pointer-events: auto; -} - -.session-chat-input-toolbar.hidden { - display: none; -} - -/* No pill is left to right-click, so the row takes back a slim hit-testable - strip to keep the visibility menu (and with it the hidden pills) reachable. */ -.session-chat-input-toolbar.empty { - pointer-events: auto; -} - -.session-chat-input-toolbar.empty .session-chat-input-toolbar-content { - min-height: var(--vscode-spacing-size120); -} - -/* The floating row is click-through, so the slider shows the scroll position but - cannot be dragged; wheeling over a pill scrolls the row. */ -.session-chat-input-toolbar > .scrollbar > .slider { - background: transparent; -} - -.session-chat-input-toolbar > .scrollbar.horizontal > .slider::before { - content: ''; - position: absolute; - inset: var(--vscode-strokeThickness); - border-radius: var(--vscode-cornerRadius-circle); - background: var(--vscode-scrollbarSlider-background); -} - -.session-chat-input-toolbar > .scrollbar > .slider:hover::before { - background: var(--vscode-scrollbarSlider-hoverBackground); -} - -.session-chat-input-toolbar > .scrollbar > .slider.active::before { - background: var(--vscode-scrollbarSlider-activeBackground); -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 2c1aa63dcb0e1b..7f8e152760f39c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -25,7 +25,6 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; -import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; @@ -37,24 +36,6 @@ const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarouse /** Action id of the references pill. */ export const SESSION_REFERENCES_PILL_ID = 'sessions.chatPills.references'; -/** - * Presentation of the references pill. References are always summarized: the - * pill answers "what did this session point me at" with a count, rather than - * turning into whichever single reference happens to be recorded. - */ -export const sessionReferencesPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionReferences', - icon: Codicon.bookmark, - title: localize('sessionReferences.title', "References"), - summaryLabel: count => count === 1 - ? localize('sessionReferences.countSingle', "1 Reference") - : localize('sessionReferences.count', "{0} References", count), - summaryAriaLabel: count => count === 1 - ? localize('sessionReferences.showSingle', "Show 1 reference") - : localize('sessionReferences.show', "Show {0} references", count), - singleEntry: ChatPillSingleEntry.Summary, -}; - const artifactIcons: ReadonlyMap = new Map([ [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], [SessionArtifactKind.Issue, Codicon.issues], diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts index 77517404c44eb4..a3c6751c88f975 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts @@ -8,7 +8,6 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; @@ -17,15 +16,6 @@ import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug. const SUBAGENT_LABEL_MAX_LENGTH = 30; -/** Presentation of the subagents pill. */ -export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionBackgroundActivities', - icon: Codicon.agent, - title: localize('backgroundActivities.ariaLabel', "Background Activities"), - summaryLabel: count => localize('backgroundActivities.subagentsSummary', "{0} Subagents", count), - summaryAriaLabel: count => localize('backgroundActivities.showSubagents', "Show {0} subagents", count), -}; - /** * Supplies the background activities of the viewed chat to its pill. Today * those are all of the chat's direct subagents, regardless of status (still diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts index d5dab1934fb970..4c932401b18721 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -10,22 +10,12 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; -import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -/** Presentation of the browsers pill. */ -export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionBrowsers', - icon: Codicon.globe, - title: localize('browsers.ariaLabel', "Browsers"), - summaryLabel: count => localize('browsers.activeBrowsers', "{0} Active Browsers", count), - summaryAriaLabel: count => localize('browsers.show', "Show {0} browsers", count), -}; - const NO_URLS: ReadonlySet = new Set(); function urlsEqual(a: ReadonlySet, b: ReadonlySet): boolean { @@ -46,7 +36,7 @@ function urlsEqual(a: ReadonlySet, b: ReadonlySet): boolean { /** Supplies the live browsers of the viewed chat (and its subagents) to its pill. */ export class SessionBrowsersControl extends Disposable { - /** The pill's sections, empty while the user has the pill hidden. */ + /** The pill's sections before the shared controller applies user visibility. */ readonly sections: IObservable; /** The URLs the pill's browsers show, empty while the user has the pill hidden. */ readonly urls: IObservable>; @@ -105,7 +95,7 @@ export class SessionBrowsersControl extends Disposable { }); this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); - this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); + this.sections = allSections; this.urls = derivedOpts>({ owner: this, equalsFn: urlsEqual }, reader => visible.read(reader) ? allUrls.read(reader) : NO_URLS); this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 17285abb69f71b..0507b237ab0df9 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -3,53 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow } from '../../../../base/browser/dom.js'; -import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; -import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; -import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; -import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { SessionArtifacts, sessionArtifactLocation, sessionReferencesPillOptions, SESSION_REFERENCES_PILL_ID } from './sessionArtifacts.js'; -import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatInputPills, StandardChatInputPillSources } from '../../../../workbench/contrib/chat/browser/chatInputPills.js'; +import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; +import { SessionCustomizations } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; -import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; -import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; -import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js'; -import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; -import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; +import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillResourceLocation, type ChatPillsCompactMode, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { computeAggregateIssueIcon, computeIssueIcon, getPullRequestStatusFromIcon, GitHubIssueState, OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID, type IGitHubIssue } from '../../github/common/types.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { IResolvedSessionPullRequest, SessionPullRequestPresentationModel } from '../../github/browser/pullRequestIconStatus.js'; +import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../../../workbench/contrib/chat/common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IChat } from '../../../services/sessions/common/session.js'; +import { getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; -import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; +import { SessionBrowsersControl } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -import { SessionMetadataPills } from './sessionMetadataPills.js'; import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; -import './media/sessionChatInputToolbar.css'; +import { computePullRequestIcon } from '../../../../workbench/common/chatPullRequest.js'; +import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; +import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { getSessionAgentMergeConfigurationObservable } from '../../../browser/sessionAgentMerge.js'; +import { createIssueHoverElement } from '../../github/browser/issueHover.js'; +import { createPullRequestHoverElement } from '../../github/browser/pullRequestHover.js'; -/** Diff stats for the current turn, from the chat''s last-turn changes. */ -function computeTurnStats(chat: IChat, reader: IReader): IDiffStats { - let files = 0, insertions = 0, deletions = 0; - for (const change of chat.lastTurnChanges?.read(reader) ?? []) { - if (change.isOutsideWorkspace) { - continue; - } - files++; - insertions += change.insertions; - deletions += change.deletions; - } - return { files, insertions, deletions }; -} /** Fake artifacts for the pill debug overlay. */ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { const entries = debugData.markdownFiles.map(name => { @@ -59,33 +51,141 @@ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): read return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } -/** Action ids of the pills the sessions toolbar hosts itself. */ -export const SESSION_BROWSERS_PILL_ID = 'sessions.chatPills.browsers'; -export const SESSION_SUBAGENTS_PILL_ID = 'sessions.chatPills.subagents'; +function getPullRequestAttention(icon: ThemeIcon, status: IResolvedSessionPullRequest['status']): string | undefined { + if (status.hasFailingChecks && status.hasUnresolvedComments) { + return localize('sessionChatPills.pullRequestFailingChecksAndComments', "failing checks and unresolved review comments"); + } + if (status.hasFailingChecks) { + return localize('sessionChatPills.pullRequestFailingChecks', "failing checks"); + } + if (status.hasUnresolvedComments || icon.id === Codicon.gitPullRequestComment.id) { + return localize('sessionChatPills.pullRequestComments', "unresolved review comments"); + } + if (icon.id === Codicon.gitPullRequestError.id) { + return localize('sessionChatPills.pullRequestAttention', "failing checks or merge conflicts"); + } + return undefined; +} -/** The pill kind a contributed or turn-status action belongs to, if any. */ -export function getSessionChatPillKindForAction(actionId: string): SessionChatPillKind | undefined { - switch (actionId) { - case CHAT_TURN_CHANGES_PILL_ID: - case VIEW_SESSION_CHANGES_COMMAND_ID: - return SessionChatPillKind.Changes; - case CHAT_TURN_ARTIFACT_PILL_ID: - return SessionChatPillKind.Artifacts; - case SESSION_REFERENCES_PILL_ID: - return SessionChatPillKind.References; - case SESSION_CUSTOMIZATIONS_PILL_ID: - return SessionChatPillKind.Customizations; - case OPEN_PULL_REQUEST_ACTION_ID: - return SessionChatPillKind.PullRequests; - case OPEN_ISSUE_ACTION_ID: - return SessionChatPillKind.Issues; - case SESSION_BROWSERS_PILL_ID: - return SessionChatPillKind.Browsers; - case SESSION_SUBAGENTS_PILL_ID: - return SessionChatPillKind.Subagents; - default: - return undefined; +function getGitHubRepositoryHoverData(owner: string, repo: string, openerService: IOpenerService) { + const repository = URI.parse(`https://github.com/${owner}/${repo}`); + return { + repositoryHref: repository.toString(true), + onDidClickRepository: () => { void openerService.open(repository, { openExternal: true }); }, + }; +} + +/** Builds Agents Window pull request pill entries, enriching them when live details are available. */ +export function buildSessionPullRequestSections(pullRequests: readonly IResolvedSessionPullRequest[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] { + const entries = pullRequests.map(({ ref, pullRequest, icon, status }) => { + const title = pullRequest?.title ?? ref.title; + const label = title + ? localize('sessionChatPills.pullRequestWithTitle', "Pull Request #{0}: {1}", ref.number, title) + : localize('sessionChatPills.pullRequest', "Pull Request #{0}", ref.number); + const resolvedIcon = icon ?? computePullRequestIcon('open'); + const attention = getPullRequestAttention(resolvedIcon, status); + const state = pullRequest?.isDraft + ? 'draft' + : pullRequest?.state ?? ref.liveState ?? ref.state ?? getPullRequestStatusFromIcon(resolvedIcon) ?? 'open'; + const stateDescription = state === 'draft' + ? localize('sessionChatPills.pullRequestDraft', "draft") + : attention ?? ( + state === 'merged' + ? localize('sessionChatPills.pullRequestMerged', "merged") + : state === 'closed' + ? localize('sessionChatPills.pullRequestClosed', "closed") + : localize('sessionChatPills.pullRequestOpen', "open") + ); + return { + id: ref.uri.toString(), + label, + pillLabel: `#${ref.number}`, + icon: resolvedIcon, + toolbarActions: [toAction({ + id: `sessionChatPills.copyPullRequest.${ref.owner}.${ref.repo}.${ref.number}`, + label: localize('sessionChatPills.copyPullRequest', "Copy Pull Request URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => clipboardService.writeText(ref.uri.toString(true)), + })], + ...getChatPillResourceLocation(ref.uri, label), + ariaDescription: localize('sessionChatPills.pullRequestDescription', "{0}. {1}", stateDescription, ref.uri.toString(true)), + ...(pullRequest ? { + pillHover: { + element: () => createPullRequestHoverElement({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), + pullRequest, + }), + }, + } : {}), + open: () => { + if (session) { + sessionsService.setActive(session); + } + void commandService.executeCommand(OPEN_PULL_REQUEST_ACTION_ID, { pullRequest: ref }); + }, + } satisfies IChatPillEntry; + }); + return entries.length > 0 ? [{ title: localize('sessionChatPills.pullRequests', "Pull Requests"), entries }] : []; +} + +interface IResolvedSessionIssue { + readonly ref: IGitHubIssueRef; + readonly issue: IGitHubIssue | undefined; +} + +/** Builds Agents Window issue pill entries, enriching them when live details are available. */ +export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] { + const entries = issues.map(({ ref, issue }) => { + const label = issue?.title + ? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, issue.title) + : localize('sessionChatPills.issue', "Issue #{0}", ref.number); + return { + id: ref.uri.toString(), + label, + pillLabel: `#${ref.number}`, + icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), + toolbarActions: [toAction({ + id: `sessionChatPills.copyIssue.${ref.owner}.${ref.repo}.${ref.number}`, + label: localize('sessionChatPills.copyIssue', "Copy Issue URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => clipboardService.writeText(ref.uri.toString(true)), + })], + ...getChatPillResourceLocation(ref.uri, label), + ...(issue ? { + pillHover: { + element: () => createIssueHoverElement({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), + issue, + }), + }, + } : {}), + open: () => { + if (session) { + sessionsService.setActive(session); + } + void commandService.executeCommand(OPEN_ISSUE_ACTION_ID, { issue: ref }); + }, + } satisfies IChatPillEntry; + }); + return entries.length > 0 ? [{ title: localize('sessionChatPills.issues', "Issues"), entries }] : []; +} + +/** Returns the session-scoped changes counts represented by the shared Changes pill. */ +export function computeSessionInputPillStats(session: IActiveSession | undefined, changesStatsCache: ISessionChangesStatsCache, reader: IReader): IDiffStats { + if (session?.worktreePending?.read(reader)) { + return EMPTY_DIFF_STATS; } + const workspace = session?.workspace.read(reader); + const stats = session && workspace + ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) + : undefined; + return stats ?? EMPTY_DIFF_STATS; } /** @@ -93,20 +193,15 @@ export function getSessionChatPillKindForAction(actionId: string): SessionChatPi * the row floats over it. Derived from the row's `2px`/`4px` padding here plus a * 22px `.monaco-text-button.small` pill; keep in sync if either changes. */ -export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = 28; +export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = CHAT_INPUT_PILLS_ROW_HEIGHT; /** A toolbar for session metadata, active-turn status, and background activity. */ export class SessionChatInputToolbar extends Disposable { readonly element: HTMLElement; - private readonly _content: HTMLElement; - private readonly _scrollable: DomScrollableElement; - private readonly _onDidChangeChatPetPlatform = this._register(new Emitter()); - readonly onDidChangeChatPetPlatform: Event = this._onDidChangeChatPetPlatform.event; - private readonly _onDidChangeVisibility = this._register(new Emitter()); - readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; - private _visible = false; - private readonly _pills: ChatPillsWidget; + readonly onDidChangeChatPetPlatform: Event; + readonly onDidChangeVisibility: Event; + private readonly _inputPills: ChatInputPills; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ private readonly _sessionOverride = observableValue(this, 'unset'); @@ -139,35 +234,32 @@ export class SessionChatInputToolbar extends Disposable { private readonly _customizationSections: IObservable; constructor( + compact: ChatPillsCompactMode, + focusFallback: (() => void) | undefined, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IClipboardService clipboardService: IClipboardService, + @ICommandService commandService: ICommandService, + @IGitHubService gitHubService: IGitHubService, @ISessionsService private readonly _sessionsService: ISessionsService, - @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, + @ISessionChangesService sessionChangesService: ISessionChangesService, + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + @IOpenerService openerService: IOpenerService, + @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._content = $('.session-chat-input-toolbar-content'); - this._scrollable = this._register(new DomScrollableElement(this._content, { - horizontal: ScrollbarVisibility.Auto, - horizontalScrollbarSize: 6, - scrollYToX: true, - vertical: ScrollbarVisibility.Hidden, - })); - this.element = this._scrollable.getDomNode(); - this.element.classList.add('session-chat-input-toolbar', 'hidden'); - this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => { const debugData = this._debugData.read(reader); if (debugData) { return debugData.stats; } - const chat = this._chat.read(reader); - return chat ? computeTurnStats(chat, reader) : EMPTY_DIFF_STATS; + return computeSessionInputPillStats(this._session.read(reader), changesStatsCache, reader); }); const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); - const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); // The browsers pill already offers the pages it lists, so the artifacts and @@ -182,183 +274,101 @@ export class SessionChatInputToolbar extends Disposable { this._customizationSections = sessionCustomizations.sections; const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); - const model: IChatTurnPillsModel = { - stats: this._diffStats, - artifacts: this._artifactSections, - changesEnabled: pillsEnabled, - artifactsEnabled: pillsEnabled, - openChanges: () => this._debugData.get() ? undefined : this._openChanges(), - }; - - const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model)); - const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session)); - - // Every pill the session currently has data for, before the user's - // per-kind visibility choices are applied. - const candidatePills = derived(reader => { - const turn = turnPills.pills.read(reader); - return [ - ...metadataPills.pills.read(reader), - ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), - ]; + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, constObservable(true))); + const gitHubInfo = derived(this, reader => { + const session = this._session.read(reader); + const workspace = session?.workspace.read(reader); + return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); }); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); - - // `show-file-icons` lets a resource pill paint its themed file icon. - const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); - const sectionPill = (id: string, label: string, sections: IObservable, options: IChatDropdownPillOptions) => { - const action = this._register(new Action(id, label)); - return createChatSectionPill(action, sections, options, resourceLabels, instantiationService); - }; - - // Customization and reference sections are not gated at the source, so gate - // them here the way the two activity controls gate their own. Data presence - // follows the feature gate but not the user's visibility choice, otherwise - // hiding the pill would drop it from the menu that restores it. - const gated = (kind: SessionChatPillKind, source: IObservable) => { - const available = derived(reader => turnStatusPillsEnabled.read(reader) ? source.read(reader) : []); - return { - hasData: derived(reader => getChatPillEntries(available.read(reader)).length > 0), - sections: derived(reader => visibility.isVisible(kind, reader) ? available.read(reader) : []), - }; - }; - const customizations = gated(SessionChatPillKind.Customizations, this._customizationSections); - const references = gated(SessionChatPillKind.References, this._referenceSections); - - // Every section-backed pill lives in the same toolbar, so the whole row is - // one tab stop with arrow-key navigation instead of one stop per pill. - // These follow the candidate pills, which is what puts References directly - // after the artifacts pill: the two read as a pair, what the session made - // and what it points at. - const sectionPills: readonly { readonly pill: IObservable; readonly sections: IObservable }[] = [ - { pill: sectionPill(SESSION_REFERENCES_PILL_ID, localize('sessionChatPills.references', "References"), references.sections, sessionReferencesPillOptions), sections: references.sections }, - { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizations.sections, chatCustomizationPillOptions), sections: customizations.sections }, - { pill: sectionPill(SESSION_BROWSERS_PILL_ID, localize('sessionChatPills.browsers', "Browsers"), this._browsers.sections, sessionBrowsersPillOptions), sections: this._browsers.sections }, - { pill: sectionPill(SESSION_SUBAGENTS_PILL_ID, localize('sessionChatPills.subagents', "Subagents"), this._backgroundActivities.sections, sessionSubagentsPillOptions), sections: this._backgroundActivities.sections }, - ]; - - const pillsModel: IChatPillsModel = { - pills: derived(reader => [ - ...candidatePills.read(reader).filter(pill => { - const kind = getSessionChatPillKindForAction(pill.action.id); - return !kind || visibility.isVisible(kind, reader); - }), - ...sectionPills - .filter(entry => getChatPillEntries(entry.sections.read(reader)).length > 0) - .map(entry => entry.pill.read(reader)), - ]), - context: this._session, - }; - const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); - const pills = this._pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { - actionRunner, - // The row's visibility menu must be reachable by right-clicking a pill, - // not just the empty space beside it. - allowContextMenu: true, - })); - pills.element.classList.add('show-file-icons'); - this._content.appendChild(pills.element); - this._register(pills.onDidChangePills(() => this._onDidChangeChatPetPlatform.fire())); - - // Kinds the session reports data for; the others are listed in a separate group. - const kindsWithData = derived(reader => { - const kinds = new Set(); - for (const pill of candidatePills.read(reader)) { - const kind = getSessionChatPillKindForAction(pill.action.id); - if (kind) { - kinds.add(kind); - } - } - if (this._browsers.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Browsers); - } - if (this._backgroundActivities.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Subagents); - } - if (customizations.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Customizations); - } - if (references.hasData.read(reader)) { - kinds.add(SessionChatPillKind.References); - } - return kinds; + const pullRequestRefs = derived(this, reader => getGitHubPullRequestRefs(gitHubInfo.read(reader))); + const agentMergeConfiguration = derived(this, reader => { + const session = this._session.read(reader); + return session ? getSessionAgentMergeConfigurationObservable(session, sessionsProvidersService, this._configurationService).read(reader) : undefined; }); - this._register(addDisposableListener(this._content, EventType.CONTEXT_MENU, (e: MouseEvent) => { - // The row owns its context menu, so never fall through to a native one. - e.preventDefault(); - e.stopPropagation(); - - const kinds = kindsWithData.get(); - if (kinds.size === 0) { + const pullRequestPresentation = this._register(new SessionPullRequestPresentationModel(pullRequestRefs, agentMergeConfiguration, gitHubService)); + const pullRequestSections = derived(this, reader => buildSessionPullRequestSections(pullRequestPresentation.pullRequests.read(reader), this._session.read(reader), commandService, clipboardService, openerService, this._sessionsService)); + const issueRefs = derived(this, reader => gitHubInfo.read(reader)?.issues ?? []); + const issues = derived(this, reader => issueRefs.read(reader).map(ref => { + const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + return { ref, issue: reference.object.issue.read(reader) }; + })); + const issuesActive = derived(this, reader => pillsEnabled.read(reader) && visibility.isVisible(SessionChatPillKind.Issues, reader)); + this._register(autorun(reader => { + if (!issuesActive.read(reader)) { return; } - - const anchor = new StandardMouseEvent(getWindow(this._content), e); - const targetPill = pills.getPill(e.target as HTMLElement | null); - const targetKind = targetPill ? getSessionChatPillKindForAction(targetPill.action.id) : undefined; - this._contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => { - const menu = getSessionChatPillMenu(kinds, visibility.readHiddenKinds(undefined), targetKind); - const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ - id: `sessions.chatPills.toggle.${entry.kind}`, - label: entry.label, - checked: entry.checked, - run: () => visibility.toggle(entry.kind), - }); - - const groups: IAction[][] = []; - if (menu.hide) { - const hide = menu.hide; - groups.push([toAction({ - id: `sessions.chatPills.hide.${hide.kind}`, - label: hide.label, - run: () => visibility.hide(hide.kind), - })]); + for (const ref of issueRefs.read(reader)) { + const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + const model = reference.object; + model.refresh(); + const shouldPoll = derived(this, pollReader => model.issue.read(pollReader)?.state !== GitHubIssueState.Closed); + reader.store.add(autorun(pollReader => { + if (shouldPoll.read(pollReader)) { + pollReader.store.add(model.startPolling()); } - groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); - return Separator.join(...groups); - }, - }); - })); - - const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => { - this._scrollable.scanDomNode(); - this._onDidChangeChatPetPlatform.fire(); - })); - this._register(resizeObserver.observe(this._content)); - this._register(resizeObserver.observe(pills.element)); - this._register(this._scrollable.onScroll(e => { - if (e.scrollLeftChanged) { - this._onDidChangeChatPetPlatform.fire(); + })); } })); - this._register(addDisposableListener(this._content, EventType.FOCUS_IN, () => this._scrollable.scanDomNode())); - - this._register(autorun(reader => { - const anyVisible = pills.isVisible.read(reader); - // Stay rendered while hidden pills have data: in read-only chats the - // input part is only kept alive by a non-hidden persistent child. - const anyHidden = kindsWithData.read(reader).size > 0; - const visible = anyVisible || anyHidden; - this.element.classList.toggle('hidden', !visible); - // With no pill left to right-click, the row itself has to carry the - // visibility menu or the hidden pills could never be restored. - this.element.classList.toggle('empty', !anyVisible); - if (this._visible !== visible) { - this._visible = visible; - this._onDidChangeVisibility.fire(visible); + const issueSections = derived(this, reader => buildSessionIssueSections(issues.read(reader), this._session.read(reader), commandService, clipboardService, openerService, this._sessionsService)); + const issueIcon = derived(this, reader => { + const resolved = issues.read(reader); + if (resolved.length === 1) { + const issue = resolved[0].issue; + return issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined); } - this._scrollable.scanDomNode(); + return computeAggregateIssueIcon(resolved.map(({ issue }) => issue)); + }); + const changesLabel = derived(this, reader => { + const workspace = this._session.read(reader)?.workspace.read(reader); + const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); + return branch + ? localize('sessionChatPills.allChangesOnBranch', "All Changes ({0})", branch) + : localize('sessionChatPills.allChanges', "All Changes"); + }); + const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { + changes: { + stats: this._diffStats, + label: changesLabel, + open: () => { + const session = this._session.get(); + if (!session || this._debugData.get()) { + return; + } + layoutService.revealEditorPartExplicitly(); + void sessionChangesService.openChangesEditor(session.resource, { changesetSelection: { kind: 'id', id: undefined } }); + }, + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestPresentation.icon }, + issues: { sections: issueSections, icon: issueIcon }, + artifacts: { sections: this._artifactSections }, + references: { sections: this._referenceSections }, + customizations: { sections: this._customizationSections }, + browsers: { sections: this._browsers.sections }, + subagents: { sections: this._backgroundActivities.sections }, + }, SESSION_CHAT_PILL_KINDS)); + const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); + this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { + debugName: 'SessionChatInputToolbar.content', + compact, + enabled: pillsEnabled, + sources: constObservable(sources.sources), + offeredKinds: SESSION_CHAT_PILL_KINDS, + context: this._session, + actionRunner, + focusFallback, })); + this.element = this._inputPills.element; + this.element.classList.add('session-chat-input-toolbar'); + this.onDidChangeChatPetPlatform = this._inputPills.onDidChange; + this.onDidChangeVisibility = this._inputPills.onDidChangeVisibility; } get visible(): boolean { - return this._visible; + return this._inputPills.visible; } getChatPetPlatformElements(): readonly HTMLElement[] { - return this._pills.getPillElements(); + return this._inputPills.getPillElements(); } /** @@ -403,13 +413,4 @@ export class SessionChatInputToolbar extends Disposable { return active?.chats.read(reader).some(c => isEqual(c.resource, chatResource)) ? active : undefined; } - private _openChanges(): void { - const chat = this._chat.get(); - if (!chat) { - return; - } - - this._chatResponseFileChangesService.openChangesForRequest(chat.resource, undefined, { isLastTurn: true }); - } - } diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts index c14e893a4f7bf0..817e6b7c77dfe1 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -13,7 +13,6 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { ISessionChatCustomization, ISessionFolder, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; @@ -22,20 +21,6 @@ import type { IActiveSession } from '../../../services/sessions/common/sessionsM /** Action id of the customizations pill. */ export const SESSION_CUSTOMIZATIONS_PILL_ID = 'sessions.chatPills.customizations'; -/** Presentation of the customizations pill. */ -export const chatCustomizationPillOptions: IChatDropdownPillOptions = { - widgetId: 'chatCustomizations', - icon: Codicon.bookmark, - title: localize('chatCustomizations.title', "Customizations"), - summaryLabel: count => count === 1 - ? localize('chatCustomizations.countSingle', "1 Customization") - : localize('chatCustomizations.count', "{0} Customizations", count), - summaryAriaLabel: count => count === 1 - ? localize('chatCustomizations.showSingle', "Show 1 customization") - : localize('chatCustomizations.show', "Show {0} customizations", count), - singleEntry: ChatPillSingleEntry.Summary, -}; - const customizationIcons: ReadonlyMap = new Map([ [SessionCustomizationKind.Agent, Codicon.robot], [SessionCustomizationKind.Skill, Codicon.lightbulb], diff --git a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts deleted file mode 100644 index a86d658fcce767..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { getWindow } from '../../../../base/browser/dom.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; -import { Event } from '../../../../base/common/event.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { IMenuService, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { ChatPillActionViewItem, IChatPill } from '../../../../workbench/browser/chatPills.js'; -import { Menus } from '../../../browser/menus.js'; -import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; -import { ISessionChangesStatsCache } from '../../../services/sessions/common/sessionChangesStatsCache.js'; - -/** Adapts the session metadata menu to observable chat-pill descriptors. */ -export class SessionMetadataPills extends Disposable { - - readonly pills: IObservable; - - private readonly _scopedInstantiationService: IInstantiationService; - - constructor( - container: HTMLElement, - session: IObservable, - @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, - @IContextKeyService contextKeyService: IContextKeyService, - @IInstantiationService instantiationService: IInstantiationService, - @IMenuService menuService: IMenuService, - @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, - ) { - super(); - - const scopedContextKeyService = this._register(contextKeyService.createScoped(container)); - this._scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( - [IContextKeyService, scopedContextKeyService], - [ISessionContext, new SessionContext(session)], - ))); - - this._register(autorun(reader => { - setSessionContextKeys(session.read(reader), scopedContextKeyService, reader, changesStatsCache); - })); - - const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true })); - const menuSignal = observableSignalFromEvent(this, Event.any( - menu.onDidChange, - Event.filter(this._actionViewItemService.onDidChange, menuId => menuId === Menus.SessionHeaderMeta), - )); - this.pills = derived(this, reader => { - menuSignal.read(reader); - return menu.getActions({ shouldForwardArgs: true }).flatMap(([group, actions]) => { - if (group !== 'navigation') { - return []; - } - return actions.map(action => ({ - action, - createActionViewItem: (options: IActionViewItemOptions) => { - const provider = this._actionViewItemService.lookUp( - Menus.SessionHeaderMeta, - action instanceof SubmenuItemAction ? action.item.submenu.id : action.id, - ); - return provider?.(action, options, this._scopedInstantiationService, getWindow(container).vscodeWindowId) - ?? this._scopedInstantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); - }, - } satisfies IChatPill)); - }); - }); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index a19980965ed8f2..46980c64880e5c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -81,8 +81,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.archiveSession', "To archive or mark one or more sessions as done, focus them in the Sessions list and invoke Archive or Mark as Done{0}.", ``)); content.push(localize('sessionsChat.deleteSession', "To permanently delete a session, open its context menu and choose Delete. This is destructive and cannot be undone.")); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); - content.push(localize('sessionsChat.viewAllChanges', "The session header shows the diff stats (lines added and removed) as a button. Activate it to open the multi-file diff editor for all of the session's changes{0}.", '')); - content.push(localize('sessionsChat.openPullRequest', "When the session is associated with a GitHub pull request, the session header shows the pull request number as a button. Activate it to open the pull request on GitHub{0}.", '')); + content.push(localize('sessionsChat.viewAllChanges', "Status pills above the chat input include the session's diff stats (lines added and removed). Activate the Changes pill to open the multi-file diff editor for all of the session's changes{0}.", '')); + content.push(localize('sessionsChat.openPullRequest', "When the session is associated with GitHub pull requests, a status pill above the chat input shows the pull request number or count. Activate it to open a single pull request or choose from the associated pull requests{0}.", '')); content.push(localize('sessionsChat.filesView', "Focus the Files Explorer view{0}.", '')); content.push(localize('sessionsChat.sessionsView', "Focus the Chat Sessions view{0}.", '')); content.push(localize('sessionsChat.customizations', "Focus the Chat Customizations view{0}.", ``)); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts index 90a05c5d3fe150..50c133463193f9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -271,11 +271,24 @@ suite('SessionBrowsersControl', () => { assert.deepStrictEqual({ visible: [...createControl({ browsers }, store).control.urls.get()], - hidden: [...createControl({ browsers, visible: false }, store).control.urls.get()], + hidden: { + urls: [...createControl({ browsers, visible: false }, store).control.urls.get()], + sections: sections(createControl({ browsers, visible: false }, store).control), + }, disabled: [...createControl({ browsers, enabled: false }, store).control.urls.get()], }, { visible: ['https://example.com/docs', 'https://preview.test/'], - hidden: [], + hidden: { + urls: [], + sections: [{ + title: 'Browsers', + entries: [ + { label: 'Docs', icon: 'globe' }, + { label: 'Subagent Preview', icon: 'globe' }, + { label: 'Blank', icon: 'globe' }, + ], + }], + }, disabled: [], }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index f8743c0f92e48f..0eb26479dae37e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -4,36 +4,183 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { isManagedHoverTooltipHTMLElement } from '../../../../../base/browser/ui/hover/hover.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, derived } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; -import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; -import { SessionChatPillKind } from '../../common/sessionChatPills.js'; -import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; -import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import type { IChatPillEntry } from '../../../../../workbench/browser/chatPills.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; +import { type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; +import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats } from '../../browser/sessionChatInputToolbar.js'; suite('SessionChatInputToolbar', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('maps turn-status and hosted pill actions onto togglable pill kinds', () => { - assert.deepStrictEqual([ - getSessionChatPillKindForAction(CHAT_TURN_CHANGES_PILL_ID), - getSessionChatPillKindForAction(VIEW_SESSION_CHANGES_COMMAND_ID), - getSessionChatPillKindForAction(CHAT_TURN_ARTIFACT_PILL_ID), - getSessionChatPillKindForAction(SESSION_CUSTOMIZATIONS_PILL_ID), - getSessionChatPillKindForAction(OPEN_PULL_REQUEST_ACTION_ID), - getSessionChatPillKindForAction(OPEN_ISSUE_ACTION_ID), - getSessionChatPillKindForAction(SESSION_BROWSERS_PILL_ID), - getSessionChatPillKindForAction(SESSION_SUBAGENTS_PILL_ID), - ], [ - SessionChatPillKind.Changes, - SessionChatPillKind.Changes, - SessionChatPillKind.Artifacts, - SessionChatPillKind.Customizations, - SessionChatPillKind.PullRequests, - SessionChatPillKind.Issues, - SessionChatPillKind.Browsers, - SessionChatPillKind.Subagents, - ]); + test('uses session-scoped changes rather than the last turn', () => { + const session = upcastPartial({ + sessionId: 'provider:session', + workspace: constObservable(upcastPartial({ folders: [] })), + changesets: constObservable([]), + changes: constObservable([{ + modifiedUri: URI.file('/session-change.ts'), + insertions: 10, + deletions: 4, + }]), + }); + const cache = upcastPartial({ + get: () => ({ files: 2, insertions: 8, deletions: 3 }), + }); + const stats = derived(reader => computeSessionInputPillStats(session, cache, reader)); + const pendingSession = upcastPartial({ + ...session, + worktreePending: constObservable(true), + }); + const pendingStats = derived(reader => computeSessionInputPillStats(pendingSession, cache, reader)); + + assert.deepStrictEqual({ + session: stats.get(), + pendingWorktree: pendingStats.get(), + }, { + session: { + files: 1, + insertions: 10, + deletions: 4, + }, + pendingWorktree: { + files: 0, + insertions: 0, + deletions: 0, + }, + }); + }); + + test('adds rich GitHub hovers only when live details are available', async () => { + const commandService = upcastPartial({ executeCommand: async () => undefined }); + const clipboardService = upcastPartial({ writeText: async () => { } }); + const openerService = upcastPartial({ open: async () => true }); + const sessionsService = upcastPartial({ setActive: () => { } }); + const pullRequestRef: IGitHubPullRequestRef = { + owner: 'microsoft', + repo: 'vscode', + number: 332982, + uri: URI.parse('https://github.com/microsoft/vscode/pull/332982'), + }; + const pullRequest: IGitHubPullRequest = { + number: pullRequestRef.number, + title: 'Restore rich pill hovers', + body: 'Provides detailed pull request context.', + state: GitHubPullRequestState.Open, + author: { login: 'octocat', avatarUrl: '' }, + headRef: 'feature/rich-hover', + headSha: 'abc123', + baseRef: 'main', + isDraft: false, + createdAt: '2026-09-03T09:00:00Z', + updatedAt: '2026-09-03T10:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', + }; + const issueRef: IGitHubIssueRef = { + owner: 'microsoft', + repo: 'vscode', + number: 42, + uri: URI.parse('https://github.com/microsoft/vscode/issues/42'), + }; + const issue: IGitHubIssue = { + number: issueRef.number, + title: 'Rich issue hover', + body: 'Provides detailed issue context.', + state: GitHubIssueState.Open, + stateReason: undefined, + author: { login: 'octocat', avatarUrl: '' }, + createdAt: '2026-09-03T09:00:00Z', + updatedAt: '2026-09-03T10:00:00Z', + closedAt: undefined, + }; + const pullRequestEntry = buildSessionPullRequestSections( + [{ ref: pullRequestRef, pullRequest, icon: Codicon.gitPullRequest, status: {} }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const unresolvedPullRequestEntry = buildSessionPullRequestSections( + [{ ref: pullRequestRef, pullRequest: undefined, icon: Codicon.gitPullRequest, status: {} }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const issueEntry = buildSessionIssueSections( + [{ ref: issueRef, issue }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const unresolvedIssueEntry = buildSessionIssueSections( + [{ ref: issueRef, issue: undefined }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + + const renderHover = async (entry: IChatPillEntry | undefined) => { + if (!isManagedHoverTooltipHTMLElement(entry?.pillHover)) { + return undefined; + } + return await entry.pillHover.element(CancellationToken.None); + }; + const pullRequestHover = await renderHover(pullRequestEntry); + const issueHover = await renderHover(issueEntry); + + assert.deepStrictEqual({ + pullRequest: { + className: pullRequestHover?.className, + repository: pullRequestHover?.querySelector('.sessions-pr-hover-repository')?.textContent, + title: pullRequestHover?.querySelector('.sessions-pr-hover-title')?.textContent, + description: pullRequestHover?.querySelector('.sessions-pr-hover-description-content')?.textContent, + branches: [...pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch') ?? []].map(element => element.textContent), + unresolvedHover: unresolvedPullRequestEntry?.pillHover, + }, + issue: { + className: issueHover?.className, + repository: issueHover?.querySelector('.sessions-issue-hover-repository')?.textContent, + title: issueHover?.querySelector('.sessions-issue-hover-title')?.textContent, + description: issueHover?.querySelector('.sessions-issue-hover-description-content')?.textContent, + unresolvedHover: unresolvedIssueEntry?.pillHover, + }, + }, { + pullRequest: { + className: 'sessions-pr-hover', + repository: 'microsoft/vscode', + title: 'Restore rich pill hovers', + description: 'Provides detailed pull request context.', + branches: ['main', 'feature/rich-hover'], + unresolvedHover: undefined, + }, + issue: { + className: 'sessions-issue-hover', + repository: 'microsoft/vscode#42', + title: 'Rich issue hover', + description: 'Provides detailed issue context.', + unresolvedHover: undefined, + }, + }); }); }); diff --git a/src/vs/sessions/contrib/github/browser/issueActions.ts b/src/vs/sessions/contrib/github/browser/issueActions.ts index 5900c93e9927fe..50ffc23f741d0c 100644 --- a/src/vs/sessions/contrib/github/browser/issueActions.ts +++ b/src/vs/sessions/contrib/github/browser/issueActions.ts @@ -3,55 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IManagedHoverContent, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { $ } from '../../../../base/browser/dom.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { arrayEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -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 { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { SessionHasIssuesContext } from '../../../common/contextkeys.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; 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, OPEN_ISSUE_ACTION_ID } from '../common/types.js'; -import { IGitHubService } from './githubService.js'; -import { createIssueHoverElement } from './issueHover.js'; -import { GitHubReferenceList, IGitHubReferenceListEntry } from './githubReferenceList.js'; - -/** A session issue paired with its live details, once they have been fetched. */ -interface IResolvedSessionIssue { - readonly ref: IGitHubIssueRef; - readonly issue: IGitHubIssue | undefined; -} - -interface IIssueListEntry extends IGitHubReferenceListEntry { - readonly owner: string; - readonly repo: string; - readonly uri: URI; -} - -// --- Open Issue action +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { OPEN_ISSUE_ACTION_ID } from '../common/types.js'; const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; const openIssueWebviewPath = '/open-issue-webview'; @@ -82,13 +46,6 @@ class OpenIssueAction extends Action2 { title: localize2('agentSessions.openIssue', 'Open Issue'), icon: Codicon.issues, f1: false, - // Metadata pill shown after pull requests. - menu: [{ - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 2, - when: SessionHasIssuesContext - }], }); } @@ -97,7 +54,6 @@ class OpenIssueAction extends Action2 { const sessionsService = accessor.get(ISessionsService); const extensionService = accessor.get(IExtensionService); const urlService = accessor.get(IURLService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const issue = isIssueActionContext(target) ? target.issue : getSessionIssues(target)[0]; if (!issue) { @@ -128,10 +84,6 @@ function getSessionIssues(session: ISession | undefined): readonly IGitHubIssueR return session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.issues ?? []; } -/** - * Copies the URL of an issue. Invoked with an {@link IssueActionContext} from the issue pill, - * so the issue that was hovered or picked is the one that gets copied. - */ class CopyIssueUrlAction extends Action2 { static readonly ID = 'workbench.agentSessions.action.copyIssueUrl'; @@ -146,7 +98,6 @@ class CopyIssueUrlAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | IssueActionContext): Promise { const clipboardService = accessor.get(IClipboardService); const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const issue = isIssueActionContext(target) ? target.issue : getSessionIssues(target)[0]; if (!issue) { @@ -157,262 +108,3 @@ class CopyIssueUrlAction extends Action2 { } } registerAction2(CopyIssueUrlAction); - -// --- Open Issue action view item - -/** - * Renders the GitHub issues a session references as a single metadata pill. - * - * A session that references one issue shows `#` and hovers to the issue's details. - * A session that references several shows ` issues` and opens a picker on click, since the - * pill then stands for a set rather than a single target. Either way the icon reflects the - * aggregate live state: open wins over closed, and closed-as-completed wins over - * closed as not planned. - * - * The issues are read from the {@link ISessionContext} so the correct per-session issues are - * shown even when several session views are visible at once. - */ -export class OpenIssueActionViewItem extends ChatPillActionViewItem { - - private readonly _issueRefsObs: IObservable; - private readonly _issuesObs: IObservable; - private readonly _issueList = this._register(new MutableDisposable>()); - private _issuePickerVisible = false; - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ICommandService private readonly _commandService: ICommandService, - @IGitHubService private readonly _gitHubService: IGitHubService, - @IOpenerService private readonly _openerService: IOpenerService, - @IHoverService private readonly _hoverService: IHoverService, - ) { - super(undefined, action, options); - - this._issueRefsObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number) - }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.issues ?? []; - }); - - this._issuesObs = derived(reader => this._issueRefsObs.read(reader).map(ref => { - const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); - return { ref, issue: reference.object.issue.read(reader) }; - })); - - // Keep the issue models warm for as long as the pill is rendered so the icon - // reflects the live state. This autorun depends only on the issue *identities*, - // so a state change does not release and re-acquire every model. - this._register(autorun(reader => { - for (const ref of this._issueRefsObs.read(reader)) { - const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); - const model = reference.object; - model.refresh(); - - // A closed issue is effectively final, so it is only fetched once. Gate the - // repeating loop on a stable boolean so poll results don't toggle it. - const shouldPoll = derived(this, pollReader => model.issue.read(pollReader)?.state !== GitHubIssueState.Closed); - reader.store.add(autorun(pollReader => { - if (shouldPoll.read(pollReader)) { - pollReader.store.add(model.startPolling()); - } - })); - } - })); - - this._register(autorun(reader => { - const issues = this._issuesObs.read(reader); - this._issueList.value?.update(this._getIssueListEntries(issues)); - this.updateLabel(); - this.updateTooltip(); - })); - } - - protected override hasOpenDropdown(): boolean { - return this._issuePickerVisible; - } - - protected override onDidClickButton(): void { - if (this.hasOpenDropdown()) { - this._hoverService.hideHover(true); - return; - } - - const issues = this._issuesObs.get(); - if (issues.length > 1) { - this._showIssuePicker(issues); - return; - } - - super.onDidClickButton(); - } - - protected override getIconElement(): HTMLElement | undefined { - const icon = this._computeIcon(); - const iconElement = $(`span.chat-pill-icon${ThemeIcon.asCSSSelector(icon)}`, { 'aria-hidden': 'true' }); - if (icon.color) { - // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon - // { color: inherit !important }`, so the glyph reflects the live issue state color. - iconElement.style.setProperty('color', asCssVariable(icon.color.id), 'important'); - } - return iconElement; - } - - protected override getLabelText(): string { - const issues = this._issuesObs.get(); - if (issues.length === 0) { - return ''; - } - return issues.length === 1 - ? `#${issues[0].ref.number}` - : localize('agentSessions.openIssue.count', "{0} issues", issues.length); - } - - protected override getHoverContents(): IManagedHoverContent | undefined { - const issues = this._issuesObs.get(); - if (issues.length !== 1) { - return this.getTooltip(); - } - - const { ref, issue } = issues[0]; - return { - element: () => createIssueHoverElement({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - repositoryHref: this._getRepositoryUri(ref).toString(true), - issue, - onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(ref), { openExternal: true }), - }), - }; - } - - protected override getHoverOptions(): IManagedHoverOptions | undefined { - const issues = this._issuesObs.get(); - if (issues.length !== 1) { - return undefined; - } - - const ref = issues[0].ref; - return { - actions: [{ - commandId: CopyIssueUrlAction.ID, - label: localize('agentSessions.issueHover.copyLink', "Copy Link"), - iconClass: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyIssueLink(ref), - }], - }; - } - - protected override getTooltip(): string { - const issues = this._issuesObs.get(); - if (issues.length > 1) { - return localize('agentSessions.openIssue.tooltipMany', "Show the {0} Issues Referenced by This Session", issues.length); - } - const number = issues[0]?.ref.number; - return number !== undefined - ? localize('agentSessions.openIssue.tooltipWithNumber', "Open Issue #{0}", number) - : localize('agentSessions.openIssue.tooltip', "Open Issue"); - } - - private _computeIcon(): ThemeIcon { - const issues = this._issuesObs.get(); - if (issues.length === 1) { - const issue = issues[0].issue; - return issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined); - } - return computeAggregateIssueIcon(issues.map(({ issue }) => issue)); - } - - private _copyIssueLink(ref: IGitHubIssueRef): void { - this._commandService.executeCommand(CopyIssueUrlAction.ID, new IssueActionContext(ref)); - } - - /** - * Shows the referenced issues below the pill. A sticky hover is used rather than a - * context menu because menu items render their icon on the label element, which would - * lose the per-issue state color. - */ - private _showIssuePicker(issues: readonly IResolvedSessionIssue[]): void { - const target = this.button?.element; - if (!target) { - return; - } - - const list = this._issueList.value = new GitHubReferenceList(this._getIssueListEntries(issues), entry => { - this._hoverService.hideHover(); - this.actionRunner.run(this._action, new IssueActionContext(entry)); - }); - - this._issuePickerVisible = true; - const hover = this._hoverService.showInstantHover({ - content: list.element, - target, - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true, hideOnKeyDown: true }, - appearance: { showPointer: false, skipFadeInAnimation: true }, - trapFocus: true, - onDidHide: () => { - this._issuePickerVisible = false; - if (this._issueList.value === list) { - this._issueList.clear(); - } - }, - }, true); - if (!hover) { - this._issuePickerVisible = false; - this._issueList.clear(); - } - } - - private _getIssueListEntries(issues: readonly IResolvedSessionIssue[]): readonly IIssueListEntry[] { - return 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), - uri: ref.uri, - toolbarActions: [toAction({ - id: CopyIssueUrlAction.ID, - label: localize('agentSessions.issueList.copyLink', "Copy Issue Link"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyIssueLink(ref), - })], - })); - } - - private _getRepositoryUri(ref: IGitHubIssueRef): URI { - return URI.parse(`https://github.com/${ref.owner}/${ref.repo}`); - } -} - -/** - * Registers the {@link OpenIssueActionViewItem} for the issue metadata pill. - */ -class OpenIssueActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.openIssueActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenIssueAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(OpenIssueActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -registerWorkbenchContribution2(OpenIssueActionViewItemContribution.ID, OpenIssueActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/issueHover.ts b/src/vs/sessions/contrib/github/browser/issueHover.ts index 5a264e130bdb1e..cd572fceae2cdd 100644 --- a/src/vs/sessions/contrib/github/browser/issueHover.ts +++ b/src/vs/sessions/contrib/github/browser/issueHover.ts @@ -17,7 +17,7 @@ export interface IIssueHoverData { readonly repo: string; readonly number: number; readonly repositoryHref: string; - readonly issue: IGitHubIssue | undefined; + readonly issue: IGitHubIssue; readonly onDidClickRepository?: () => void; } @@ -39,14 +39,14 @@ export function createIssueHoverElement(data: IIssueHoverData): HTMLElement { }; } - const date = formatIssueDate(data.issue?.createdAt); + const date = formatIssueDate(data.issue.createdAt); if (date) { append(header, $('span.sessions-issue-hover-date', undefined, localize('agentSessions.issueHover.onDate', "on {0}", date))); } - append(hoverElement, $('.sessions-issue-hover-title', undefined, data.issue?.title || localize('agentSessions.issueHover.titleFallback', "Issue #{0}", data.number))); + append(hoverElement, $('.sessions-issue-hover-title', undefined, data.issue.title || localize('agentSessions.issueHover.titleFallback', "Issue #{0}", data.number))); - const body = data.issue?.body.trim() || localize('agentSessions.issueHover.bodyFallback', "No description provided."); + const body = data.issue.body.trim() || localize('agentSessions.issueHover.bodyFallback', "No description provided."); const description = append(hoverElement, $('.sessions-issue-hover-description')); append(description, $('.sessions-issue-hover-description-content', undefined, body)); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index e6948f1506f58e..f0a153eeff94b9 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -3,66 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IManagedHoverContent, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { $ } from '../../../../base/browser/dom.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { arrayEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -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 { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { Menus } from '../../../browser/menus.js'; -import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; -import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration } from '../../../browser/sessionAgentMerge.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { getGitHubPullRequestRefs, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.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'; -import { IPullRequestIconCache } from './pullRequestIconCache.js'; -import { computePullRequestIconStatus } from './pullRequestIconStatus.js'; - -interface IResolvedSessionPullRequest { - readonly ref: IGitHubPullRequestRef; - readonly pullRequest: IGitHubPullRequest | undefined; - readonly icon: ThemeIcon | undefined; - readonly status: IPullRequestIconStatus; -} - -interface IPullRequestIdentity { - readonly owner: string; - readonly repo: string; - readonly number: number; -} - -interface IPullRequestListEntry extends IGitHubReferenceListEntry { - readonly owner: string; - readonly repo: string; - readonly uri: URI; -} - -// --- Open Pull Request action +import { OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; class PullRequestActionContext { constructor(readonly pullRequest: IGitHubPullRequestRef) { } @@ -90,13 +43,7 @@ class OpenPullRequestAction extends Action2 { title: localize2('agentSessions.openPullRequest', "Open Pull Request"), icon: Codicon.gitPullRequest, f1: false, - // Metadata pill that summarizes the session's pull requests. menu: [{ - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 1, - when: SessionHasPullRequestContext - }, { id: Menus.SessionItemContextMenu, group: '2_pullRequest', order: 0, @@ -107,7 +54,6 @@ class OpenPullRequestAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); if (!pullRequest) { @@ -120,8 +66,6 @@ class OpenPullRequestAction extends Action2 { } registerAction2(OpenPullRequestAction); -// --- Copy Pull Request URL action - function getSessionPullRequest(session: ISession | undefined): IGitHubPullRequestRef | undefined { const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); return getGitHubPullRequestRefs(gitHubInfo)[0]; @@ -147,7 +91,6 @@ class CopyPullRequestUrlAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const clipboardService = accessor.get(IClipboardService); const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); if (!pullRequest) { @@ -158,335 +101,3 @@ class CopyPullRequestUrlAction extends Action2 { } } registerAction2(CopyPullRequestUrlAction); - -// --- Open Pull Request action view item (session header pull request pill) - -/** - * Renders the session's pull requests as a single header pill and opens a picker for history. - */ -export class OpenPullRequestActionViewItem extends ChatPillActionViewItem { - - private readonly _pullRequestRefsObs: IObservable; - private readonly _pullRequestIdentitiesObs: IObservable; - private readonly _pullRequestsObs: IObservable; - private readonly _agentMergeConfiguration: IObservable; - private readonly _icon: IObservable; - private readonly _pullRequestList = this._register(new MutableDisposable>()); - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IConfigurationService configurationService: IConfigurationService, - @ICommandService private readonly _commandService: ICommandService, - @IGitHubService private readonly _gitHubService: IGitHubService, - @IPullRequestIconCache private readonly _pullRequestIconCache: IPullRequestIconCache, - @IOpenerService private readonly _openerService: IOpenerService, - @IHoverService private readonly _hoverService: IHoverService, - ) { - super(undefined, action, options); - - this._agentMergeConfiguration = derived(this, reader => { - const session = sessionContext.session.read(reader); - return session ? getSessionAgentMergeConfigurationObservable(session, sessionsProvidersService, configurationService).read(reader) : undefined; - }); - this._pullRequestRefsObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => - x.owner === y.owner && - x.repo === y.repo && - x.number === y.number && - isEqual(x.uri, y.uri) && - (x.icon === y.icon || (!!x.icon && !!y.icon && ThemeIcon.isEqual(x.icon, y.icon)))) - }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); - return getGitHubPullRequestRefs(gitHubInfo); - }); - - this._pullRequestIdentitiesObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number) - }, reader => this._pullRequestRefsObs.read(reader).map(({ owner, repo, number }) => ({ owner, repo, number }))); - - this._pullRequestsObs = derived(reader => this._pullRequestRefsObs.read(reader).map((ref, index) => { - const reference = reader.store.add(this._gitHubService.createPullRequestModelReference(ref.owner, ref.repo, ref.number)); - const pullRequest = reference.object.pullRequest.read(reader); - const status = pullRequest ? computePullRequestIconStatus(reader, this._gitHubService, ref.owner, ref.repo, pullRequest) : {}; - const icon = pullRequest - ? computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state, status) - : this._pullRequestIconCache.get(ref.uri.toString()) ?? ref.icon ?? (index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined); - if (pullRequest && icon) { - this._pullRequestIconCache.set(ref.uri.toString(), icon); - } - return { - ref, - pullRequest, - icon, - status, - }; - })); - this._icon = derived(this, reader => { - const agentMerge = this._agentMergeConfiguration.read(reader); - const icons = this._pullRequestsObs.read(reader).map(pullRequest => - pullRequest.icon ? getAgentMergeAwarePullRequestIcon(pullRequest.icon, agentMerge, pullRequest.status) : undefined); - return getHighestPriorityPullRequestIcon(icons) ?? Codicon.gitPullRequest; - }); - - this._register(autorun(reader => { - for (const identity of this._pullRequestIdentitiesObs.read(reader)) { - const reference = reader.store.add(this._gitHubService.createPullRequestModelReference(identity.owner, identity.repo, identity.number)); - const model = reference.object; - model.refresh(); - - const shouldPoll = derived(this, pollReader => { - const state = model.pullRequest.read(pollReader)?.state; - return state === undefined || state === GitHubPullRequestState.Open; - }); - reader.store.add(autorun(pollReader => { - if (shouldPoll.read(pollReader)) { - pollReader.store.add(model.startPolling()); - } - })); - - reader.store.add(autorun(statusReader => { - const pullRequest = model.pullRequest.read(statusReader); - if (!pullRequest || pullRequest.isDraft || pullRequest.state !== GitHubPullRequestState.Open) { - return; - } - - const ciReference = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(identity.owner, identity.repo, identity.number, pullRequest.headSha)); - ciReference.object.refresh(); - statusReader.store.add(ciReference.object.startPolling()); - - const reviewThreadsReference = statusReader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(identity.owner, identity.repo, identity.number)); - reviewThreadsReference.object.refresh(); - statusReader.store.add(reviewThreadsReference.object.startPolling()); - })); - } - })); - - this._register(autorun(reader => { - const pullRequests = this._pullRequestsObs.read(reader); - this._icon.read(reader); - this._pullRequestList.value?.update(this._getPullRequestListEntries(pullRequests)); - this.updateLabel(); - this.updateTooltip(); - })); - } - - protected override hasOpenDropdown(): boolean { - return !!this._pullRequestList.value; - } - - protected override onDidClickButton(): void { - if (this.hasOpenDropdown()) { - this._hoverService.hideHover(true); - return; - } - - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length > 1) { - this._showPullRequestPicker(pullRequests); - return; - } - - super.onDidClickButton(); - } - - protected override getIconElement(): HTMLElement | undefined { - const icon = this._icon.get(); - const iconElement = $(`span.chat-pill-icon${ThemeIcon.asCSSSelector(icon)}`, { 'aria-hidden': 'true' }); - if (icon.color) { - // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon - // { color: inherit !important }`, so the glyph reflects the live PR state color. - iconElement.style.setProperty('color', asCssVariable(icon.color.id), 'important'); - } - return iconElement; - } - - protected override getLabelText(): string { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length === 0) { - return ''; - } - return pullRequests.length === 1 - ? `#${pullRequests[0].ref.number}` - : localize('agentSessions.openPullRequest.count', "{0} Pull Requests", pullRequests.length); - } - - protected override getHoverContents(): IManagedHoverContent | undefined { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length !== 1) { - return this.getTooltip(); - } - - const { ref, pullRequest } = pullRequests[0]; - return { - element: () => createPullRequestHoverElement({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - repositoryHref: this._getRepositoryUri(ref).toString(true), - pullRequest, - onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(ref), { openExternal: true }), - }), - }; - } - - protected override getHoverOptions(): IManagedHoverOptions | undefined { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length !== 1) { - return undefined; - } - - const ref = pullRequests[0].ref; - return { - actions: [{ - commandId: CopyPullRequestUrlAction.ID, - label: localize('agentSessions.pullRequestHover.copyLink', "Copy Link"), - iconClass: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyPullRequestLink(ref), - }], - }; - } - - protected override getTooltip(): string { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length > 1) { - return localize('agentSessions.openPullRequest.tooltipMany', "Show the {0} Pull Requests Associated with This Session", pullRequests.length); - } - const number = pullRequests[0]?.ref.number; - return number !== undefined - ? localize('agentSessions.openPullRequest.tooltipWithNumber', "Open Pull Request #{0}", number) - : localize('agentSessions.openPullRequest.tooltip', "Open Pull Request"); - } - - private _copyPullRequestLink(ref: IGitHubPullRequestRef): void { - this._commandService.executeCommand(CopyPullRequestUrlAction.ID, new PullRequestActionContext(ref)); - } - - private _showPullRequestPicker(pullRequests: readonly IResolvedSessionPullRequest[]): void { - const target = this.button?.element; - if (!target) { - return; - } - - const list = this._pullRequestList.value = new GitHubReferenceList(this._getPullRequestListEntries(pullRequests), entry => { - this._hoverService.hideHover(); - this.actionRunner.run(this._action, new PullRequestActionContext(entry)); - }); - list.element.onkeydown = event => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - this._hoverService.hideHover(); - } - }; - - const hover = this._hoverService.showInstantHover({ - content: list.element, - target, - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true, hideOnKeyDown: false }, - appearance: { showPointer: false, skipFadeInAnimation: true }, - trapFocus: true, - onDidHide: () => { - if (this._pullRequestList.value === list) { - this._pullRequestList.clear(); - } - }, - }, true); - if (!hover) { - this._pullRequestList.clear(); - } - } - - private _getRepositoryUri(ref: { readonly owner: string; readonly repo: string }): URI { - return URI.parse(`https://github.com/${ref.owner}/${ref.repo}`); - } - - 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: icon ?? Codicon.gitPullRequest, - uri: ref.uri, - ariaLabel: getPullRequestAriaLabel(ref, pullRequest, status), - toolbarActions: [toAction({ - id: CopyPullRequestUrlAction.ID, - label: localize('agentSessions.pullRequestList.copyLink', "Copy Pull Request Link"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyPullRequestLink(ref), - })], - })); - } -} - -function getPullRequestAriaLabel(ref: IGitHubPullRequestRef, pullRequest: IGitHubPullRequest | undefined, status: IPullRequestIconStatus): string { - let kind: string; - if (pullRequest?.isDraft) { - kind = localize('agentSessions.pullRequestList.draft', "Draft Pull Request"); - } else { - switch (pullRequest?.state) { - case GitHubPullRequestState.Open: - kind = localize('agentSessions.pullRequestList.open', "Open Pull Request"); - break; - case GitHubPullRequestState.Merged: - kind = localize('agentSessions.pullRequestList.merged', "Merged Pull Request"); - break; - case GitHubPullRequestState.Closed: - kind = localize('agentSessions.pullRequestList.closed', "Closed Pull Request"); - break; - default: - kind = localize('agentSessions.pullRequestList.pullRequest', "Pull Request"); - } - } - - const baseLabel = pullRequest?.title - ? localize('agentSessions.pullRequestList.labelWithTitle', "{0} #{1}: {2}", kind, ref.number, pullRequest.title) - : localize('agentSessions.pullRequestList.label', "{0} #{1}", kind, ref.number); - - let attention: string | undefined; - if (status.hasFailingChecks && status.hasUnresolvedComments) { - attention = localize('agentSessions.pullRequestList.failingChecksAndUnresolvedComments', "failing checks and unresolved comments"); - } else if (status.hasFailingChecks) { - attention = localize('agentSessions.pullRequestList.failingChecks', "failing checks"); - } else if (status.hasUnresolvedComments) { - attention = localize('agentSessions.pullRequestList.unresolvedComments', "unresolved comments"); - } - - return attention - ? localize('agentSessions.pullRequestList.labelWithAttention', "{0}, {1}", baseLabel, attention) - : baseLabel; -} - -/** - * Registers the {@link OpenPullRequestActionViewItem} for the pull-request metadata pill. - */ -class OpenPullRequestActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.openPullRequestActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenPullRequestAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(OpenPullRequestActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -registerWorkbenchContribution2(OpenPullRequestActionViewItemContribution.ID, OpenPullRequestActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestHover.ts b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts index d09b65699e74c8..2b74d06a62d887 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestHover.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts @@ -17,7 +17,7 @@ export interface IPullRequestHoverData { readonly repo: string; readonly number: number; readonly repositoryHref: string; - readonly pullRequest: IGitHubPullRequest | undefined; + readonly pullRequest: IGitHubPullRequest; readonly onDidClickRepository?: () => void; } @@ -39,21 +39,21 @@ export function createPullRequestHoverElement(data: IPullRequestHoverData): HTML }; } - const date = formatPullRequestDate(data.pullRequest?.createdAt); + const date = formatPullRequestDate(data.pullRequest.createdAt); if (date) { append(header, $('span.sessions-pr-hover-date', undefined, localize('agentSessions.pullRequestHover.onDate', "on {0}", date))); } - append(hoverElement, $('.sessions-pr-hover-title', undefined, data.pullRequest?.title || localize('agentSessions.pullRequestHover.titleFallback', "Pull Request #{0}", data.number))); + append(hoverElement, $('.sessions-pr-hover-title', undefined, data.pullRequest.title || localize('agentSessions.pullRequestHover.titleFallback', "Pull Request #{0}", data.number))); - const body = data.pullRequest?.body.trim() || localize('agentSessions.pullRequestHover.bodyFallback', "No description provided."); + const body = data.pullRequest.body.trim() || localize('agentSessions.pullRequestHover.bodyFallback', "No description provided."); const description = append(hoverElement, $('.sessions-pr-hover-description')); append(description, $('.sessions-pr-hover-description-content', undefined, body)); const branchRow = append(hoverElement, $('.sessions-pr-hover-branches')); - appendBranchPill(branchRow, data.pullRequest?.baseRef || localize('agentSessions.pullRequestHover.baseFallback', "target")); + appendBranchPill(branchRow, data.pullRequest.baseRef || localize('agentSessions.pullRequestHover.baseFallback', "target")); append(branchRow, $('span.sessions-pr-hover-branch-arrow', undefined, '\u2190')); - appendBranchPill(branchRow, data.pullRequest?.headRef || localize('agentSessions.pullRequestHover.headFallback', "source")); + appendBranchPill(branchRow, data.pullRequest.headRef || localize('agentSessions.pullRequestHover.headFallback', "source")); return hoverElement; } diff --git a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts index 32d7f44126418e..72102ee49afe85 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts @@ -3,13 +3,59 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IReaderWithStore } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, IReaderWithStore } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; +import { getAgentMergeAwarePullRequestIcon, ISessionAgentMergeConfiguration } from '../../../browser/sessionAgentMerge.js'; +import { getHighestPriorityPullRequestIcon, IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { IPullRequestIconCache } from './pullRequestIconCache.js'; +export interface IResolvedSessionPullRequest { + readonly ref: IGitHubPullRequestRef; + readonly pullRequest: IGitHubPullRequest | undefined; + readonly icon: ThemeIcon | undefined; + readonly status: IPullRequestIconStatus; +} + +/** + * Resolves live pull-request presentation data shared by session surfaces. + * Polling is owned centrally by the GitHub polling contribution. + */ +export class SessionPullRequestPresentationModel extends Disposable { + + readonly pullRequests: IObservable; + readonly icon: IObservable; + + constructor( + pullRequestRefs: IObservable, + agentMergeConfiguration: IObservable, + gitHubService: IGitHubService, + ) { + super(); + + this.pullRequests = derived(this, reader => pullRequestRefs.read(reader).map((ref, index) => { + const reference = reader.store.add(gitHubService.createPullRequestModelReference(ref.owner, ref.repo, ref.number)); + const pullRequest = reference.object.pullRequest.read(reader); + const status = pullRequest ? computePullRequestIconStatus(reader, gitHubService, ref.owner, ref.repo, pullRequest) : {}; + const icon = pullRequest + ? computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state, status) + : ref.icon ?? (index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined); + return { + ref, + pullRequest, + status, + icon: icon ? getAgentMergeAwarePullRequestIcon(icon, agentMergeConfiguration.read(reader), status) : undefined, + }; + })); + this.icon = derived(this, reader => { + const icons = this.pullRequests.read(reader).map(pullRequest => pullRequest.icon); + return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon(GitHubPullRequestState.Open); + }); + } +} + /** * Reads the live {@link IPullRequestIconStatus} for a pull request from the shared * CI and review-thread models. The status only refines open, non-draft pull requests diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 494505089490af..f88873c7996662 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -5,6 +5,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js'; +import { computePullRequestIcon, type ChatPullRequestState, type IPullRequestIconStatus } from '../../../../workbench/common/chatPullRequest.js'; + +export { computePullRequestIcon, type IPullRequestIconStatus }; export const OPEN_PULL_REQUEST_ACTION_ID = 'workbench.agentSessions.action.openPullRequest'; export const OPEN_ISSUE_ACTION_ID = 'workbench.agentSessions.action.openIssue'; @@ -148,48 +151,8 @@ export interface IGitHubPullRequestReview { readonly submittedAt: string; } -/** - * Additional live status used to refine the icon of an open pull request. - */ -export interface IPullRequestIconStatus { - /** Whether the pull request has merge conflicts. */ - readonly hasMergeConflicts?: boolean; - /** Whether the pull request has at least one failing CI check. */ - readonly hasFailingChecks?: boolean; - /** Whether the pull request has at least one unresolved review comment thread. */ - readonly hasUnresolvedComments?: boolean; -} - -/** - * Compute the PR status icon from a state value. - * Accepts both the `GitHubPullRequestState` enum values and the - * metadata-only `'draft'` value the extension writes to session metadata. - * - * For open (non-draft) pull requests the optional {@link IPullRequestIconStatus} - * refines the icon: a failing CI check shows an error variant (orange), while an - * unresolved review comment shows a comment variant (using the open PR green). - */ -export function computePullRequestIcon(state: GitHubPullRequestState | 'draft', status?: IPullRequestIconStatus): ThemeIcon { - switch (state) { - case GitHubPullRequestState.Merged: - return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; - case GitHubPullRequestState.Closed: - return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; - case 'draft': - return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; - case GitHubPullRequestState.Open: - if (status?.hasMergeConflicts || status?.hasFailingChecks) { - return { ...Codicon.gitPullRequestError, color: themeColorFromId('charts.orange') }; - } - if (status?.hasUnresolvedComments) { - return { ...Codicon.gitPullRequestComment, color: themeColorFromId('charts.green') }; - } - return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; - } -} - /** Coarse pull request state, recoverable from the icon carried on session GitHub info. */ -export type PullRequestStatus = 'open' | 'closed' | 'merged' | 'draft'; +export type PullRequestStatus = ChatPullRequestState; /** * Inverse of {@link computePullRequestIcon}: recovers the coarse pull request diff --git a/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts b/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts deleted file mode 100644 index 0e059b2628e5e1..00000000000000 --- a/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { addDisposableListener, EventType } from '../../../../../base/browser/dom.js'; -import { mainWindow } from '../../../../../base/browser/window.js'; -import { Action } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ChatPillActionViewItem } from '../../../../../workbench/browser/chatPills.js'; -import { getAgentMergeAwarePullRequestIcon, ISessionAgentMergeConfiguration } from '../../../../browser/sessionAgentMerge.js'; -import { OpenIssueActionViewItem } from '../../browser/issueActions.js'; -import { OpenPullRequestActionViewItem } from '../../browser/pullRequestActions.js'; - -interface IIssueViewItemTestHarness { - _issuePickerVisible: boolean; - readonly _issuesObs: { get(): readonly object[] }; - readonly _hoverService: { hideHover(force?: boolean): void }; - hasOpenDropdown(): boolean; - _showIssuePicker(issues: readonly object[]): void; -} - -interface IPullRequestViewItemTestHarness { - _pullRequestList: object | undefined; - readonly _pullRequestsObs: { get(): readonly { readonly icon?: ThemeIcon }[] }; - readonly _icon?: { get(): ThemeIcon }; - readonly _hoverService: { hideHover(force?: boolean): void }; - hasOpenDropdown(): boolean; - _showPullRequestPicker(pullRequests: readonly object[]): void; -} - -const openIssueViewItemOnDidClickButton = Reflect.get(OpenIssueActionViewItem.prototype, 'onDidClickButton') as (this: IIssueViewItemTestHarness) => void; -const openPullRequestViewItemOnDidClickButton = Reflect.get(OpenPullRequestActionViewItem.prototype, 'onDidClickButton') as (this: IPullRequestViewItemTestHarness) => void; -const openPullRequestViewItemGetIconElement = Reflect.get(OpenPullRequestActionViewItem.prototype, 'getIconElement') as (this: IPullRequestViewItemTestHarness) => HTMLElement; - -class TestDropdownMetaActionViewItem extends ChatPillActionViewItem { - - dropdownVisible = true; - opened = 0; - closed = 0; - - protected override hasOpenDropdown(): boolean { - return this.dropdownVisible; - } - - protected override onDidClickButton(): void { - if (this.dropdownVisible) { - this.dropdownVisible = false; - this.closed++; - } else { - this.dropdownVisible = true; - this.opened++; - } - } -} - -suite('GitHub Reference Action View Items', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('preserves an open dropdown for primary activation while allowing secondary dismissal', () => { - const store = new DisposableStore(); - const container = mainWindow.document.createElement('div'); - mainWindow.document.body.appendChild(container); - - try { - const action = store.add(new Action('test', 'Test')); - const viewItem = store.add(new TestDropdownMetaActionViewItem(undefined, action, {})); - viewItem.render(container); - let ancestorMouseDowns = 0; - store.add(addDisposableListener(container, EventType.MOUSE_DOWN, () => ancestorMouseDowns++)); - store.add(addDisposableListener(mainWindow.document, EventType.MOUSE_DOWN, () => viewItem.dropdownVisible = false)); - const button = container.querySelector('.chat-pill-button')!; - - button.dispatchEvent(new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 0 })); - button.dispatchEvent(new MouseEvent(EventType.CLICK, { bubbles: true })); - - const afterPrimaryClick = { - ancestorMouseDowns, - opened: viewItem.opened, - closed: viewItem.closed, - dropdownVisible: viewItem.dropdownVisible, - }; - viewItem.dropdownVisible = true; - button.dispatchEvent(new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 2 })); - - assert.deepStrictEqual({ - afterPrimaryClick, - ancestorMouseDowns, - dropdownVisible: viewItem.dropdownVisible, - }, { - afterPrimaryClick: { - ancestorMouseDowns: 1, - opened: 0, - closed: 1, - dropdownVisible: false, - }, - ancestorMouseDowns: 2, - dropdownVisible: false, - }); - } finally { - store.dispose(); - container.remove(); - } - }); - - test('clicking an open issues list closes it instead of reopening it', () => { - const events: string[] = []; - const harness: IIssueViewItemTestHarness = { - _issuePickerVisible: false, - _issuesObs: { get: () => [{}, {}] }, - _hoverService: { hideHover: force => events.push(`hide:${force}`) }, - hasOpenDropdown() { - return this._issuePickerVisible; - }, - _showIssuePicker() { - events.push('show'); - this._issuePickerVisible = true; - }, - }; - - openIssueViewItemOnDidClickButton.call(harness); - openIssueViewItemOnDidClickButton.call(harness); - - assert.deepStrictEqual(events, ['show', 'hide:true']); - }); - - test('clicking an open pull request list closes it instead of reopening it', () => { - const events: string[] = []; - const harness: IPullRequestViewItemTestHarness = { - _pullRequestList: undefined, - _pullRequestsObs: { get: () => [{}, {}] }, - _hoverService: { hideHover: force => events.push(`hide:${force}`) }, - hasOpenDropdown() { - return !!this._pullRequestList; - }, - _showPullRequestPicker() { - events.push('show'); - this._pullRequestList = {}; - }, - }; - - openPullRequestViewItemOnDidClickButton.call(harness); - openPullRequestViewItemOnDidClickButton.call(harness); - - assert.deepStrictEqual(events, ['show', 'hide:true']); - }); - - test('Agent Merge shows the open pull request icon instead of blocker variants', () => { - const agentMerge = (overrides: Partial = {}): ISessionAgentMergeConfiguration => ({ - enabled: true, - actions: { - addressReviews: true, - fixCI: true, - resolveConflicts: true, - mergePullRequest: 'never', - mergeMethod: 'auto', - replyAttribution: true, - ...overrides, - }, - }); - let icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge(), { hasFailingChecks: true }); - const harness: IPullRequestViewItemTestHarness = { - _pullRequestList: undefined, - _pullRequestsObs: { get: () => [{ icon }] }, - _icon: { get: () => icon }, - _hoverService: { hideHover() { } }, - hasOpenDropdown: () => false, - _showPullRequestPicker() { }, - }; - const iconId = () => [...openPullRequestViewItemGetIconElement.call(harness).classList] - .find(className => className.startsWith('codicon-git-pull-request')); - - const failingCI = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge({ addressReviews: false }), { hasFailingChecks: true, hasUnresolvedComments: true }); - const unhandledReviewAlongsideCI = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge(), {}); - const unknownBlocker = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge({ fixCI: false }), { hasFailingChecks: true }); - const failingCIDisabled = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestComment, agentMerge()); - const reviewComments = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestComment, agentMerge({ addressReviews: false })); - - assert.deepStrictEqual({ - failingCI, - unhandledReviewAlongsideCI, - unknownBlocker, - failingCIDisabled, - reviewComments, - reviewsDisabled: iconId(), - }, { - failingCI: 'codicon-git-pull-request', - unhandledReviewAlongsideCI: 'codicon-git-pull-request-error', - unknownBlocker: 'codicon-git-pull-request-error', - failingCIDisabled: 'codicon-git-pull-request-error', - reviewComments: 'codicon-git-pull-request', - reviewsDisabled: 'codicon-git-pull-request-comment', - }); - }); -}); 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 983af869ce5caa..53b4affd69a224 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts @@ -5,10 +5,12 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { mock } from '../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { defaultAgentMergeConfiguration } from '../../../../../platform/agentHost/common/agentMerge.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'; @@ -19,6 +21,12 @@ import { SessionHasPullRequestContext } from '../../../../common/contextkeys.js' import { IGitHubPullRequestRef, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import '../../browser/pullRequestActions.js'; +import { SessionPullRequestPresentationModel } from '../../browser/pullRequestIconStatus.js'; +import { IGitHubService } from '../../browser/githubService.js'; +import { GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest } from '../../common/types.js'; +import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullRequestRefs?: readonly IGitHubPullRequestRef[]): ISession { const workspaceUri = URI.from({ scheme: 'test', path: '/workspace' }); @@ -66,7 +74,108 @@ class TestOpenerService extends mock() { suite('Pull Request Actions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('shared presentation model applies Agent Merge to live pull request status', () => { + const pullRequest = upcastPartial({ + number: 1, + title: 'Fix pills', + state: GitHubPullRequestState.Open, + headSha: 'abc123', + isDraft: false, + }); + const pullRequestModel = upcastPartial({ + pullRequest: constObservable(pullRequest), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const ciModel = upcastPartial({ + overallStatus: constObservable(GitHubCIOverallStatus.Failure), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const reviewThreadsModel = upcastPartial({ + reviewThreads: constObservable([]), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const gitHubService = upcastPartial({ + createPullRequestModelReference: () => ({ object: pullRequestModel, dispose: () => { } }), + createPullRequestCIModelReference: () => ({ object: ciModel, dispose: () => { } }), + createPullRequestReviewThreadsModelReference: () => ({ object: reviewThreadsModel, dispose: () => { } }), + }); + const model = store.add(new SessionPullRequestPresentationModel( + constObservable([{ + owner: 'microsoft', + repo: 'vscode', + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + }]), + constObservable({ + enabled: true, + actions: { + ...defaultAgentMergeConfiguration, + fixCI: true, + resolveConflicts: false, + addressReviews: false, + }, + }), + gitHubService, + )); + + assert.deepStrictEqual({ + entryIcon: model.pullRequests.get()[0].icon?.id, + summaryIcon: model.icon.get().id, + }, { + entryIcon: Codicon.gitPullRequest.id, + summaryIcon: Codicon.gitPullRequest.id, + }); + }); + + test('shared presentation model does not own polling', () => { + let refreshCount = 0; + let pollingStartCount = 0; + let pollingStopCount = 0; + const pullRequestModel = upcastPartial({ + pullRequest: constObservable(undefined), + refresh: async () => { refreshCount++; }, + startPolling: () => { + pollingStartCount++; + return toDisposable(() => pollingStopCount++); + }, + }); + const gitHubService = upcastPartial({ + createPullRequestModelReference: () => ({ object: pullRequestModel, dispose: () => { } }), + }); + const pullRequestRefs = observableValue('pullRequestActions.refs', [{ + owner: 'microsoft', + repo: 'vscode', + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + icon: Codicon.gitPullRequest, + }]); + const model = store.add(new SessionPullRequestPresentationModel(pullRequestRefs, constObservable(undefined), gitHubService)); + + model.pullRequests.get(); + pullRequestRefs.set([{ + ...pullRequestRefs.get()[0], + icon: Codicon.gitPullRequestDone, + title: 'Updated title', + }], undefined); + model.pullRequests.get(); + + assert.deepStrictEqual({ + refreshCount, + pollingStartCount, + pollingStopCount, + entryIcon: model.pullRequests.get()[0].icon?.id, + }, { + refreshCount: 0, + pollingStartCount: 0, + pollingStopCount: 0, + entryIcon: Codicon.gitPullRequestDone.id, + }); + }); test('Open Pull Request and Copy Pull Request URL are contributed to a dedicated context menu group', () => { const items = MenuRegistry.getMenuItems(Menus.SessionItemContextMenu) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index a6682134ae66c6..fd38a742ac6634 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -13,7 +13,7 @@ import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { isMultiRootSession } from '../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; -import { AGENT_MERGE_CHANGESET_ID, resolveChangesetUriTemplate } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { AGENT_MERGE_CHANGESET_ID, ChangesetKind, resolveChangesetUriTemplate, selectDefaultChangeset } from '../../../../../platform/agentHost/common/changesetUri.js'; import { isAgentMergeMessage } from '../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; import { ChangesetOperationTargetKind } from '../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, ChangesetOperationStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -25,14 +25,6 @@ import { isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat import { changesetFileToChange } from './agentHostDiffs.js'; import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; -const enum ChangesetKind { - Branch = 'branch', - Uncommitted = 'uncommitted', - Session = 'session', - Turn = 'turn', - Compare = 'compare-turns', -} - export interface IAgentHostChangeset extends Changeset { /** * Optional authoritative changes. `undefined` falls back to the changeset @@ -92,8 +84,7 @@ export function createChangesets( const sessionChangesets: ISessionChangeset[] = []; - const defaultKind = options.defaultChangesetKind ?? ChangesetKind.Branch; - const defaultChangeset = changesets.find(c => c.changeKind === defaultKind) ?? changesets[0]; + const defaultChangeset = selectDefaultChangeset(changesets, options.defaultChangesetKind); for (const catalogueEntry of changesets) { const isDefault = catalogueEntry === defaultChangeset; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts index 50b3bb0ea14523..643c6e10b3322b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts @@ -204,7 +204,7 @@ suite('Open Agent Host State File', () => { const connectionsService = new class extends mock() { override resolveSessionResource(session: URI) { calls.resolved.push(session.toString()); - return { connection, backendSession }; + return { connection, connectionAuthority: 'local', backendSession }; } }(); const editorService = new class extends mock() { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 49c7d2ab6f285a..aecff61bf5893f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -47,7 +47,8 @@ import { IAgentHostFilterService } from '../../../../services/agentHostFilter/co import { IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionSchemeAlias, IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; +import { IAgentHostSessionSchemeAlias } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from './cloudSandboxSessionsProvider.js'; import { IRemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; import { createCloudSandboxConnectionCustomization, isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; @@ -59,7 +60,7 @@ const LOG_PREFIX = '[CloudSandboxAgentHost]'; * Mission Control creates every sandbox session as `ahp-session:/` while the host advertises the * `copilot` agent, so the two schemes name the same session. */ -const SANDBOX_SESSION_SCHEME_ALIAS: ISessionSchemeAlias = { +const SANDBOX_SESSION_SCHEME_ALIAS: IAgentHostSessionSchemeAlias = { ui: CLOUD_SANDBOX_AGENT_PROVIDER, backend: CLOUD_SANDBOX_SESSION_SCHEME, }; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 5cb5124e969233..9a8a93f1f4cda8 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -18,6 +18,7 @@ import { agentHostUri } from '../../../../../platform/agentHost/common/agentHost import { AGENT_HOST_SCHEME, agentHostAuthority, type AgentHostUriMapper, fromAgentHostUri, toAgentHostContentUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService, type IAgentHostSessionSchemeAlias } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { IRemoteAgentHostService, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; @@ -80,7 +81,7 @@ export interface IRemoteAgentHostSessionsProviderConfig { * the cloud sandbox host does (sessions are `ahp-session:/` while the agent is `copilot`). * The provider derives both directions from this pair, so they cannot drift apart. */ - readonly sessionSchemeAlias?: ISessionSchemeAlias; + readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; /** * Suppresses the `[host]` suffix that otherwise disambiguates this host's workspaces from * identically-named ones on other hosts. Set by hosts whose label names a task rather than a @@ -103,13 +104,6 @@ export interface IRemoteAgentHostSessionsProviderConfig { * The two names a session goes by when the host's session scheme differs from its agent provider. * The raw session id is shared, so only the scheme is translated. */ -export interface ISessionSchemeAlias { - /** Scheme the UI routes by — the agent provider (e.g. `copilot`). */ - readonly ui: string; - /** Scheme the host's session registry is keyed by (e.g. `ahp-session`). */ - readonly backend: string; -} - /** * Sessions provider for a remote agent host connection. A thin subclass of * {@link BaseAgentHostSessionsProvider} that adds the connection-lifecycle @@ -189,7 +183,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; private readonly _disconnectOnDemand: (() => Promise) | undefined; - private readonly _sessionSchemeAlias: ISessionSchemeAlias | undefined; + private readonly _sessionSchemeAlias: IAgentHostSessionSchemeAlias | undefined; private readonly _omitHostFromWorkspaceLabel: boolean; private readonly _workspaceTypeIcon: ThemeIcon | undefined; private readonly _defaultChangesetKind: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; @@ -213,6 +207,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @INotificationService private readonly _notificationService: INotificationService, @IStorageService storageService: IStorageService, @IAgentHostService private readonly _localAgentHostService: IAgentHostService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -237,6 +232,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._omitHostFromWorkspaceLabel = config.omitHostFromWorkspaceLabel === true; this._workspaceTypeIcon = config.workspaceTypeIcon; this._defaultChangesetKind = config.defaultChangesetKind; + if (this._sessionSchemeAlias || this._defaultChangesetKind) { + this._register(agentHostConnectionsService.registerSessionResolutionPolicy(this._connectionAuthority, { + sessionSchemeAlias: this._sessionSchemeAlias, + defaultChangesetKind: this._defaultChangesetKind, + })); + } this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.autoConnect = config.autoConnect; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 3bc1ebb5f511a4..630048ad813d3b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -8,12 +8,13 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService, type IAgentHostSessionResolutionPolicy, type IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -241,7 +242,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; sessionSchemeAlias?: IAgentHostSessionSchemeAlias; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; sessionResolutionPolicies?: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }>; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -268,6 +269,12 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); instantiationService.stub(IAgentHostService, overrides?.localAgentHostService ?? new class extends mock() { }()); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + registerSessionResolutionPolicy: (authority, policy) => { + overrides?.sessionResolutionPolicies?.push({ authority, policy }); + return Disposable.None; + }, + })); instantiationService.stub(IProgressService, {}); instantiationService.stub(ILabelService, overrides?.labelService ?? new MockLabelService()); instantiationService.stub(ILogService, new NullLogService()); @@ -297,6 +304,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host', omitHostFromWorkspaceLabel: overrides?.omitHostFromWorkspaceLabel, workspaceTypeIcon: overrides?.workspaceTypeIcon, + sessionSchemeAlias: overrides?.sessionSchemeAlias, defaultChangesetKind: overrides?.defaultChangesetKind, devContainerWorktreeScope: overrides?.devContainerWorktreeScope, }; @@ -385,6 +393,24 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [My Host]'); }); + test('registers provider-owned session resolution policy', () => { + const policies: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }> = []; + createProvider(disposables, connection, { + address: 'sandbox.example', + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + sessionResolutionPolicies: policies, + }); + + assert.deepStrictEqual(policies, [{ + authority: agentHostAuthority('sandbox.example'), + policy: { + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + }, + }]); + }); + test('session types update when the host advertises additional agents', () => { const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: false }); assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [ diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 7002c36acdef20..150141410cd5fd 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../base/common/codicons.js'; import { arrayEquals } from '../../../../base/common/equals.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IObservable, IReader } from '../../../../base/common/observable.js'; @@ -12,8 +11,11 @@ import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { getHighestPriorityPullRequestIcon } from '../../../../workbench/common/chatPullRequest.js'; import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +export { getHighestPriorityPullRequestIcon }; + export interface ISessionType { /** Unique identifier (e.g., 'copilot-cli', 'copilot-cloud', 'agent-host-claude'). */ readonly id: string; @@ -388,32 +390,6 @@ export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): r }]; } -const pullRequestIconPriority = new Map([ - [Codicon.gitPullRequestError.id, 6], - [Codicon.gitPullRequestComment.id, 5], - [Codicon.gitPullRequest.id, 4], - [Codicon.gitPullRequestDraft.id, 3], - [Codicon.gitPullRequestDone.id, 2], - [Codicon.gitPullRequestClosed.id, 1], -]); - -/** Returns the most important status icon across a session's pull requests. */ -export function getHighestPriorityPullRequestIcon(icons: readonly (ThemeIcon | undefined)[]): ThemeIcon | undefined { - let result: ThemeIcon | undefined; - let resultPriority = -1; - for (const icon of icons) { - if (!icon) { - continue; - } - const priority = pullRequestIconPriority.get(icon.id) ?? 0; - if (priority > resultPriority) { - result = icon; - resultPriority = priority; - } - } - return result; -} - /** A GitHub issue referenced by a session. */ export interface IGitHubIssueRef { /** GitHub repository owner of the issue. */ diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index 8414c0b0bada9d..0620e78ae89198 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -5,6 +5,7 @@ import { $ } from '../../base/browser/dom.js'; import { IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; +import type { IManagedHoverContent, IManagedHoverOptions } from '../../base/browser/ui/hover/hover.js'; import { IAction } from '../../base/common/actions.js'; import { Codicon } from '../../base/common/codicons.js'; import { onUnexpectedError } from '../../base/common/errors.js'; @@ -43,7 +44,7 @@ export interface IChatDropdownPillOptions { /** Identifies the pill's dropdown to the action widget service. */ readonly widgetId: string; /** Icon of the summary shown for several entries. */ - readonly icon: ThemeIcon; + readonly icon: ThemeIcon | IObservable; /** Accessible name of the dropdown. */ readonly title: string; /** Summary label, e.g. `3 Artifacts`. */ @@ -95,17 +96,51 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } private _dropdownVisible = false; + private _entries: readonly IChatPillEntry[] = []; + private _summaryIcon: ThemeIcon | undefined; protected override renderContent(): void { this._register(autorun(reader => { - this._sections.read(reader); - this.updateLabel(); - this.updateTooltip(); - this.updateAriaLabel(); - this._updatePopupState(); + const previous = this._getPresentation(); + this._entries = getChatPillEntries(this._sections.read(reader)); + this._summaryIcon = ThemeIcon.isThemeIcon(this._pillOptions.icon) ? this._pillOptions.icon : this._pillOptions.icon.read(reader); + const current = this._getPresentation(); + if (previous.label !== current.label || previous.summarized !== current.summarized || !iconsEqual(previous.icon, current.icon)) { + this.updateLabel(); + } + if (previous.hoverContent !== current.hoverContent) { + this.updateTooltip(); + } + if (previous.ariaLabel !== current.ariaLabel) { + this.updateAriaLabel(); + } + if (previous.ariaDescription !== current.ariaDescription) { + this._updateAriaDescription(current.ariaDescription); + } + if (previous.summarized !== current.summarized) { + this._updatePopupState(); + } + if (this._dropdownVisible) { + if (current.summarized) { + this._actionWidgetService.updateItems(this._getDropdownItems()); + } else { + this._actionWidgetService.hide(); + } + } })); } + private _getPresentation(): { readonly summarized: boolean; readonly icon: ThemeIcon | undefined; readonly label: string; readonly hoverContent: IManagedHoverContent; readonly ariaLabel: string | undefined; readonly ariaDescription: string | undefined } { + return { + summarized: this.isSummarized, + icon: this.isSummarized ? this._summaryIcon : this.entries.at(0)?.icon, + label: this.getLabelText(), + hoverContent: this.getHoverContents(), + ariaLabel: this.getAriaLabel(), + ariaDescription: this.isSummarized ? undefined : this.entries.at(0)?.ariaDescription, + }; + } + /** * A summarized pill opens a listbox, so it has to advertise the popup and * whether it is open. A single-entry pill activates directly and must not. @@ -124,6 +159,18 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } } + private _updateAriaDescription(description: string | undefined): void { + const element = this.button?.element; + if (!element) { + return; + } + if (description) { + element.setAttribute('aria-description', description); + } else { + element.removeAttribute('aria-description'); + } + } + /** Whether the pill stands for its entries rather than showing a single one. */ protected get isSummarized(): boolean { const entries = this.entries; @@ -131,11 +178,11 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } protected get entries(): readonly IChatPillEntry[] { - return getChatPillEntries(this._sections.get()); + return this._entries; } protected override getIconElement(): HTMLElement | undefined { - const icon = this.isSummarized ? this._pillOptions.icon : this.entries.at(0)?.icon; + const icon = this.isSummarized ? this._summaryIcon : this.entries.at(0)?.icon; return icon ? this.createIconElement(icon) : undefined; } @@ -153,7 +200,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { protected override getLabelText(): string { return this.isSummarized ? this._pillOptions.summaryLabel(this.entries.length) - : this.entries.at(0)?.label ?? ''; + : this.entries.at(0)?.pillLabel ?? this.entries.at(0)?.label ?? ''; } protected override getAdditionalLabelContent(): Array { @@ -168,12 +215,31 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return entry?.tooltip ?? entry?.label ?? this._pillOptions.title; } + protected override getHoverContents(): IManagedHoverContent { + return this.isSummarized + ? super.getHoverContents() + : this.entries.at(0)?.pillHover ?? super.getHoverContents(); + } + protected override getAriaLabel(): string | undefined { return this.isSummarized ? this._pillOptions.summaryAriaLabel(this.entries.length) : this.entries.at(0)?.ariaLabel ?? super.getAriaLabel(); } + protected override getHoverOptions(): IManagedHoverOptions | undefined { + const toolbarActions = this.isSummarized ? undefined : this.entries.at(0)?.toolbarActions; + return toolbarActions?.length ? { + trapFocus: true, + actions: toolbarActions.map(action => ({ + commandId: action.id, + label: action.label, + iconClass: action.class, + run: () => { void action.run(); }, + })), + } : undefined; + } + protected override onDidClickButton(): void { if (!this.isSummarized) { this.openEntry(this.entries.at(0)); @@ -204,26 +270,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return; } - const items: IActionListItem[] = []; - for (const section of sections) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - items.push({ kind: ActionListItemKind.Header, label: section.title, group: { title: section.title } }); - for (const entry of section.entries) { - items.push({ - kind: ActionListItemKind.Action, - label: entry.label, - group: { title: '', ...(entry.icon ? { icon: entry.icon } : {}) }, - ...(entry.resource ? { iconClasses: getIconClasses(this._modelService, this._languageService, entry.resource, FileKind.FILE) } : {}), - ...(entry.toolbarActions?.length ? { toolbarActions: [...entry.toolbarActions] } : {}), - ariaDescription: entry.ariaDescription, - hover: entry.hover, - item: entry, - }); - } - } - + const items = this._getDropdownItems(sections); const delegate: IActionListDelegate = { onSelect: entry => { this._actionWidgetService.hide(); @@ -232,7 +279,9 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { onHide: () => { this._dropdownVisible = false; this._updatePopupState(); - trigger.focus(); + if (trigger.isConnected) { + trigger.focus(); + } }, }; this._dropdownVisible = true; @@ -252,6 +301,40 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { { minWidth: 240, maxWidth: 460, widgetClassName: 'show-file-icons' }, ); } + + private _getDropdownItems(sections = this._sections.get().filter(section => section.entries.length > 0)): IActionListItem[] { + const items: IActionListItem[] = []; + for (const section of sections) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ kind: ActionListItemKind.Header, label: section.title, group: { title: section.title } }); + for (const entry of section.entries) { + items.push({ + kind: ActionListItemKind.Action, + label: entry.label, + group: { title: '', ...(entry.icon ? { icon: entry.icon } : {}) }, + ...(entry.resource ? { iconClasses: getIconClasses(this._modelService, this._languageService, entry.resource, FileKind.FILE) } : {}), + ...(entry.toolbarActions?.length ? { toolbarActions: [...entry.toolbarActions] } : {}), + ariaDescription: entry.ariaDescription, + hover: entry.hover, + item: entry, + }); + } + } + return items; + } + + override dispose(): void { + if (this._dropdownVisible) { + this._actionWidgetService.hide(true); + } + super.dispose(); + } +} + +function iconsEqual(first: ThemeIcon | undefined, second: ThemeIcon | undefined): boolean { + return first === second || (!!first && !!second && ThemeIcon.isEqual(first, second)); } /** @@ -275,8 +358,16 @@ export function createChatSectionPill( return entry?.resource ? entry : undefined; }); const isResource = derived(reader => !!singleResourceEntry.read(reader)); + const resourcePill: IChatPill = { + action, + createActionViewItem: viewItemOptions => new ChatResourcePillActionViewItem(action, viewItemOptions, singleResourceEntry, resourceLabels), + }; + const dropdownPill: IChatPill = { + action, + createActionViewItem: viewItemOptions => instantiationService.createInstance(ChatDropdownPillActionViewItem, action, viewItemOptions, sections, options), + }; return derived(reader => isResource.read(reader) - ? { action, createActionViewItem: viewItemOptions => new ChatResourcePillActionViewItem(action, viewItemOptions, singleResourceEntry, resourceLabels) } - : { action, createActionViewItem: viewItemOptions => instantiationService.createInstance(ChatDropdownPillActionViewItem, action, viewItemOptions, sections, options) }); + ? resourcePill + : dropdownPill); } diff --git a/src/vs/workbench/browser/chatPills.ts b/src/vs/workbench/browser/chatPills.ts index 784537fe2381fa..c8601a174b3d2f 100644 --- a/src/vs/workbench/browser/chatPills.ts +++ b/src/vs/workbench/browser/chatPills.ts @@ -3,16 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, EventType, reset } from '../../base/browser/dom.js'; +import { $, addDisposableListener, DisposableResizeObserver, EventHelper, EventType, isHTMLElement, reset } from '../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../base/browser/keyboardEvent.js'; +import { mainWindow, type CodeWindow } from '../../base/browser/window.js'; import { IActionViewItem } from '../../base/browser/ui/actionbar/actionbar.js'; import { BaseActionViewItem, IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../base/browser/ui/button/button.js'; +import type { IManagedHoverContent } from '../../base/browser/ui/hover/hover.js'; +import { DomScrollableElement } from '../../base/browser/ui/scrollbar/scrollableElement.js'; import { ToolBar } from '../../base/browser/ui/toolbar/toolbar.js'; import { IAction, IActionRunner } from '../../base/common/actions.js'; +import { disposableTimeout } from '../../base/common/async.js'; import { Emitter, Event } from '../../base/common/event.js'; +import { MarkdownString } from '../../base/common/htmlContent.js'; +import { KeyCode } from '../../base/common/keyCodes.js'; import { isMacintosh } from '../../base/common/platform.js'; -import { Disposable } from '../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../base/common/lifecycle.js'; import { autorun, derived, IObservable } from '../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../base/common/scrollable.js'; import { ThemeIcon } from '../../base/common/themables.js'; import { URI } from '../../base/common/uri.js'; import { localize } from '../../nls.js'; @@ -41,6 +49,8 @@ export interface IChatPillsModel { export interface IChatPillEntry { readonly id: string; readonly label: string; + /** Short label used when this entry renders as the pill itself. */ + readonly pillLabel?: string; readonly icon?: ThemeIcon; /** Renders the entry with its resource's themed file icon. */ readonly resource?: URI; @@ -54,6 +64,8 @@ export interface IChatPillEntry { readonly hover?: IActionListItemHover; /** Tooltip for the pill when this is the only entry. */ readonly tooltip?: string; + /** Rich hover content for the pill when this is the only entry. */ + readonly pillHover?: IManagedHoverContent; open(): void; } @@ -63,6 +75,17 @@ export interface IChatPillSection { readonly entries: readonly IChatPillEntry[]; } +/** Describes a pill entry's target while keeping its accessible name action-oriented. */ +export function getChatPillResourceLocation(uri: URI, label: string, ariaLabel = localize('chatPills.open', "Open {0}", label)): Pick { + const value = uri.toString(true); + return { + ariaDescription: value, + ariaLabel, + hover: { content: new MarkdownString().appendText(value) }, + tooltip: value, + }; +} + export function getChatPillEntries(sections: readonly IChatPillSection[]): readonly IChatPillEntry[] { return sections.flatMap(section => section.entries); } @@ -77,6 +100,169 @@ export interface IChatPillsWidgetOptions { readonly allowContextMenu?: boolean; } +/** + * The floating row's rendered height: 2px/4px vertical padding around a 22px + * small button. Hosts reserve this much transcript space while the row is shown. + */ +export const CHAT_INPUT_PILLS_ROW_HEIGHT = 28; + +export type ChatPillsCompactMode = boolean | 'auto'; + +export interface IChatPillsRowOptions { + /** Collapses pills to their icons and uses tighter spacing while preserving full accessible labels and tooltips. */ + readonly compact?: ChatPillsCompactMode; + /** Window that owns the row. Required when rendering in an auxiliary window. */ + readonly targetWindow?: CodeWindow; +} + +/** Shared horizontally scrollable row for pills mounted above a chat input. */ +export class ChatPillsRow extends Disposable { + + readonly element: HTMLElement; + readonly content: HTMLElement; + + private readonly _scrollable: DomScrollableElement; + private readonly _resizeObserver: DisposableResizeObserver; + private readonly _mutationObserver: MutationObserver | undefined; + private _expandedContentWidth: number | undefined; + private _isLayouting = false; + private readonly _onDidChangeLayout = this._register(new Emitter()); + readonly onDidChangeLayout: Event = this._onDidChangeLayout.event; + private readonly _onDidRequestContextMenu = this._register(new Emitter()); + readonly onDidRequestContextMenu: Event = this._onDidRequestContextMenu.event; + private readonly _pendingFocus = this._register(new MutableDisposable()); + + constructor(debugName: string, options?: IChatPillsRowOptions) { + super(); + + const targetWindow = options?.targetWindow ?? mainWindow; + this.content = $('.chat-pills-row-content'); + this._scrollable = this._register(new DomScrollableElement(this.content, { + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize: 6, + scrollYToX: true, + vertical: ScrollbarVisibility.Hidden, + })); + this.element = this._scrollable.getDomNode(); + this.element.classList.add('chat-pills-row'); + const compactMode = options?.compact ?? false; + this.element.classList.toggle('compact', compactMode === true); + + this._resizeObserver = this._register(new DisposableResizeObserver(debugName, entries => { + if (entries.some(entry => entry.target !== this.content)) { + this._expandedContentWidth = undefined; + } + this.layout(); + }, targetWindow)); + this._register(this._resizeObserver.observe(this.content)); + if (compactMode === 'auto') { + this._mutationObserver = new targetWindow.MutationObserver(() => { + this._expandedContentWidth = undefined; + this.layout(); + }); + this._observeMutations(); + this._register({ dispose: () => this._mutationObserver?.disconnect() }); + } else { + this._mutationObserver = undefined; + } + this._register(this._scrollable.onScroll(event => { + if (event.scrollLeftChanged) { + this._onDidChangeLayout.fire(); + } + })); + this._register(addDisposableListener(this.content, EventType.FOCUS_IN, () => this.scanDomNode())); + this._register(addDisposableListener(this.content, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + const target = isHTMLElement(event.target) ? event.target : this.content; + const activatesEmptyRow = target === this.content && (keyboardEvent.keyCode === KeyCode.Enter || keyboardEvent.keyCode === KeyCode.Space); + if (activatesEmptyRow + || keyboardEvent.keyCode === KeyCode.ContextMenu + || (keyboardEvent.shiftKey && keyboardEvent.keyCode === KeyCode.F10)) { + EventHelper.stop(event, true); + this._onDidRequestContextMenu.fire(target); + } + })); + } + + observe(element: HTMLElement): void { + this._register(this._resizeObserver.observe(element)); + this.layout(); + } + + layout(): void { + if (this._isLayouting || !this.element.isConnected) { + return; + } + + this._isLayouting = true; + this._mutationObserver?.disconnect(); + try { + if (this._mutationObserver) { + const availableWidth = this.element.getBoundingClientRect().width; + if (this._expandedContentWidth === undefined || !this.element.classList.contains('compact')) { + const wasCompact = this.element.classList.contains('compact'); + this.element.classList.remove('compact'); + const expandedContentWidth = [...this.content.children].reduce((width, child) => { + return isHTMLElement(child) ? Math.max(width, child.offsetLeft + child.offsetWidth) : width; + }, 0); + if (expandedContentWidth > 0 || this.content.children.length === 0) { + this._expandedContentWidth = expandedContentWidth; + } + this.element.classList.toggle('compact', wasCompact); + } + this.element.classList.toggle('compact', availableWidth > 0 && this._expandedContentWidth !== undefined && this._expandedContentWidth > availableWidth + 1); + } + this.scanDomNode(); + this._onDidChangeLayout.fire(); + } finally { + this._observeMutations(); + this._isLayouting = false; + } + } + + scanDomNode(): void { + this._scrollable.scanDomNode(); + } + + private _observeMutations(): void { + this._mutationObserver?.observe(this.content, { + attributes: true, + attributeFilter: ['class', 'hidden', 'style'], + characterData: true, + childList: true, + subtree: true, + }); + } + + setEmpty(empty: boolean, ariaLabel: string): void { + this.element.classList.toggle('empty', empty); + if (empty) { + this.content.tabIndex = 0; + this.content.setAttribute('role', 'button'); + this.content.setAttribute('aria-label', ariaLabel); + this.content.setAttribute('aria-haspopup', 'menu'); + } else { + this.content.removeAttribute('tabindex'); + this.content.removeAttribute('role'); + this.content.removeAttribute('aria-label'); + this.content.removeAttribute('aria-haspopup'); + } + } + + restoreFocus(getPillElements: () => readonly HTMLElement[], fallback?: () => void): void { + this._pendingFocus.value = disposableTimeout(() => { + const pill = getPillElements().at(0); + if (pill) { + pill.focus(); + } else if (this.element.classList.contains('empty')) { + this.content.focus(); + } else { + fallback?.(); + } + }); + } +} + /** * A reusable horizontal toolbar whose pill set and action context are observable. */ @@ -86,12 +272,12 @@ export class ChatPillsWidget extends Disposable { readonly isVisible: IObservable; private readonly _onDidChangePills = this._register(new Emitter()); readonly onDidChangePills: Event = this._onDidChangePills.event; + private readonly _onDidRemoveFocusedPill = this._register(new Emitter()); + readonly onDidRemoveFocusedPill: Event = this._onDidRemoveFocusedPill.event; - private readonly _toolbar: ToolBar; + private readonly _toolbar: ChatPillsToolBar; private _pillByAction = new Map(); private _pills: readonly IChatPill[] = []; - private _pillViewItems: ChatPillActionViewItemBase[] = []; - constructor( model: IChatPillsModel, options: IChatPillsWidgetOptions | undefined, @@ -100,15 +286,12 @@ export class ChatPillsWidget extends Disposable { super(); this.element = $('.chat-pills.hidden'); - this._toolbar = this._register(new ToolBar(this.element, contextMenuService, { + this._toolbar = this._register(new ChatPillsToolBar(this.element, contextMenuService, { ariaLabel: options?.ariaLabel ?? localize('chatPills.ariaLabel', "Chat status"), actionRunner: options?.actionRunner, allowContextMenu: options?.allowContextMenu, actionViewItemProvider: (action, viewItemOptions) => { const viewItem = this._pillByAction.get(action)?.createActionViewItem?.(viewItemOptions) ?? new ChatPillActionViewItem(undefined, action, viewItemOptions); - if (viewItem instanceof ChatPillActionViewItemBase) { - this._pillViewItems.push(viewItem); - } return viewItem; }, })); @@ -120,9 +303,32 @@ export class ChatPillsWidget extends Disposable { this._toolbar.context = model.context?.read(reader); const pillsChanged = pills.length !== this._pills.length || pills.some((pill, index) => pill !== this._pills[index]); if (pillsChanged) { + const focusedPill = this._pills.find((_pill, index) => { + const viewItem = this._toolbar.getItemViewItem(index); + return viewItem instanceof ChatPillActionViewItemBase && viewItem.isFocused(); + }); + const expandedPill = focusedPill ? undefined : this._pills.find((_pill, index) => { + const viewItem = this._toolbar.getItemViewItem(index); + return viewItem instanceof ChatPillActionViewItemBase && viewItem.buttonElement?.getAttribute('aria-expanded') === 'true'; + }); + const focusOwner = focusedPill ?? expandedPill; + const focusedAction = focusOwner?.action; + const focusedIndexBeforeUpdate = focusOwner ? this._pills.indexOf(focusOwner) : -1; + const previousPills = this._pills; this._pills = pills; - this._pillViewItems = []; - this._toolbar.setActions(pills.map(pill => pill.action)); + this._toolbar.setPills(previousPills, pills); + const expandedPillPreserved = expandedPill && pills.includes(expandedPill); + if (!expandedPillPreserved) { + let focusedIndex = focusedAction ? pills.findIndex(pill => pill.action === focusedAction) : -1; + if (focusedIndex < 0 && focusedIndexBeforeUpdate >= 0 && pills.length > 0) { + focusedIndex = Math.min(focusedIndexBeforeUpdate, pills.length - 1); + } + if (focusedIndex >= 0) { + this._toolbar.focus(focusedIndex); + } else if (focusedIndexBeforeUpdate >= 0) { + this._onDidRemoveFocusedPill.fire(); + } + } } this.element.classList.toggle('hidden', pills.length === 0); if (pillsChanged) { @@ -133,7 +339,14 @@ export class ChatPillsWidget extends Disposable { /** Returns the rendered button for each pill. */ getPillElements(): readonly HTMLElement[] { - return this._pillViewItems.flatMap(viewItem => viewItem.buttonElement ? [viewItem.buttonElement] : []); + const elements: HTMLElement[] = []; + for (let index = 0; index < this._toolbar.getItemsLength(); index++) { + const viewItem = this._toolbar.getItemViewItem(index); + if (viewItem instanceof ChatPillActionViewItemBase && viewItem.buttonElement) { + elements.push(viewItem.buttonElement); + } + } + return elements; } /** @@ -150,6 +363,48 @@ export class ChatPillsWidget extends Disposable { } } +/** Updates only the changed middle of a pill toolbar so stable pills keep their DOM and focus. */ +class ChatPillsToolBar extends ToolBar { + setPills(previous: readonly IChatPill[], next: readonly IChatPill[]): void { + let prefix = 0; + while (prefix < previous.length && prefix < next.length && previous[prefix] === next[prefix]) { + prefix++; + } + + let suffix = 0; + while (suffix < previous.length - prefix + && suffix < next.length - prefix + && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) { + suffix++; + } + + for (let index = previous.length - suffix - 1; index >= prefix; index--) { + this.actionBar.pull(index); + } + for (let index = prefix; index < next.length - suffix; index++) { + this.actionBar.push(next[index].action, { icon: true, label: false, index }); + } + let focusedIndex = -1; + for (let index = 0; index < this.getItemsLength(); index++) { + const viewItem = this.getItemViewItem(index); + if (viewItem instanceof ChatPillActionViewItemBase && viewItem.isFocused()) { + focusedIndex = index; + break; + } + } + let focusableSet = false; + for (let index = 0; index < this.getItemsLength(); index++) { + const viewItem = this.getItemViewItem(index); + if (!(viewItem instanceof BaseActionViewItem)) { + continue; + } + const focusable: boolean = focusedIndex >= 0 ? index === focusedIndex : !focusableSet && viewItem.isEnabled(); + viewItem.setFocusable(focusable); + focusableSet ||= focusable; + } + } +} + /** Opaque base so a pill never shows the content it floats over; `chatPills.css` tints it. */ const chatPillBackground = asCssVariableWithDefault('chat.list.background', asCssVariable(buttonSecondaryBackground)); @@ -275,9 +530,7 @@ export abstract class ChatPillActionViewItemBase extends BaseActionViewItem { } } -/** - * Compact `icon + label` rendering, the default for chat pill actions. - */ +/** The default `icon + label` rendering for chat pill actions. */ export class ChatPillActionViewItem extends ChatPillActionViewItemBase { constructor(context: unknown, action: IAction, options: IActionViewItemOptions) { diff --git a/src/vs/workbench/browser/chatResourcePill.ts b/src/vs/workbench/browser/chatResourcePill.ts index 27df706b631449..3b3ae934dbc9aa 100644 --- a/src/vs/workbench/browser/chatResourcePill.ts +++ b/src/vs/workbench/browser/chatResourcePill.ts @@ -3,11 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../base/browser/dom.js'; import { IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../base/browser/ui/button/button.js'; import { IAction } from '../../base/common/actions.js'; +import { Codicon } from '../../base/common/codicons.js'; import { onUnexpectedError } from '../../base/common/errors.js'; import { IObservable, autorun } from '../../base/common/observable.js'; +import { ThemeIcon } from '../../base/common/themables.js'; import { localize } from '../../nls.js'; import { FileKind } from '../../platform/files/common/files.js'; import { ChatPillActionViewItemBase, type IChatPillEntry } from './chatPills.js'; @@ -32,6 +35,7 @@ export class ChatResourcePillActionViewItem extends ChatPillActionViewItemBase { } protected override renderContent(button: Button): void { + button.element.appendChild($(`span.chat-pill-icon.chat-resource-pill-compact-icon${ThemeIcon.asCSSSelector(Codicon.file)}`, { 'aria-hidden': 'true' })); const label = this._register(this._resourceLabels.create(button.element)); this._register(autorun(reader => { const entry = this._entry.read(reader); @@ -40,6 +44,11 @@ export class ChatResourcePillActionViewItem extends ChatPillActionViewItemBase { } this.updateTooltip(); this.updateAriaLabel(); + if (entry?.ariaDescription) { + button.element.setAttribute('aria-description', entry.ariaDescription); + } else { + button.element.removeAttribute('aria-description'); + } })); } diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index c82a6dff8bb6f0..ac98af9b7ff6f1 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -132,6 +132,10 @@ padding-left: var(--vscode-spacing-size20); } +.chat-pill-item .monaco-button.chat-resource-pill-button .chat-resource-pill-compact-icon { + display: none; +} + .chat-resource-pill-button .monaco-icon-label { align-items: center; min-width: 0; @@ -164,3 +168,93 @@ background-repeat: no-repeat; background-position: center center; } + +/* Horizontally scrollable status pills above a chat input. */ +.chat-pills-row { + width: 100%; + min-width: 0; +} + +.chat-pills-row-content { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--vscode-spacing-size60); + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40) 0; +} + +.chat-pills-row-content > .chat-pills { + flex-shrink: 0; +} + +.chat-pills-row-content > * { + pointer-events: auto; +} + +.chat-pills-row.hidden { + display: none; +} + +.chat-pills-row.compact .chat-pills-row-content, +.chat-pills-row.compact .chat-pills .monaco-action-bar .actions-container { + gap: var(--vscode-spacing-size40); +} + +.chat-pills-row.compact .chat-pill-item .monaco-button.chat-pill-button { + gap: var(--vscode-spacing-size20); + padding-left: var(--vscode-spacing-size40); + padding-right: var(--vscode-spacing-size40); +} + +/* Compact rows are truly collapsed rather than merely tighter: all current + pill renderers keep their identifying icon while labels, counters and dropdown + chevrons remain available through the button's tooltip and accessible name. */ +.chat-pills-row.compact .chat-pill-label, +.chat-pills-row.compact .monaco-animated-counter, +.chat-pills-row.compact .chat-pill-chevron, +.chat-pills-row.compact .chat-resource-pill-button .monaco-icon-label { + display: none; +} + +.chat-pills-row.compact .chat-pill-item .monaco-button.chat-resource-pill-button .chat-resource-pill-compact-icon { + display: inline-flex; +} + +/* Keep the visibility menu reachable when every pill with data is hidden. */ +.chat-pills-row.empty { + pointer-events: auto; +} + +.chat-pills-row.empty .chat-pills-row-content { + min-height: var(--vscode-spacing-size120); +} + +.chat-pills-row.empty .chat-pills-row-content:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + border-radius: var(--vscode-cornerRadius-small); +} + +/* The floating row is click-through, so its slider is informational only. */ +.chat-pills-row > .scrollbar > .slider { + background: transparent; +} + +.chat-pills-row > .scrollbar.horizontal > .slider::before { + content: ''; + position: absolute; + inset: var(--vscode-strokeThickness); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-scrollbarSlider-background); +} + +.chat-pills-row > .scrollbar > .slider:hover::before { + background: var(--vscode-scrollbarSlider-hoverBackground); +} + +.chat-pills-row > .scrollbar > .slider.active::before { + background: var(--vscode-scrollbarSlider-activeBackground); +} diff --git a/src/vs/workbench/common/chatPullRequest.ts b/src/vs/workbench/common/chatPullRequest.ts new file mode 100644 index 00000000000000..c2ca02cad41cbb --- /dev/null +++ b/src/vs/workbench/common/chatPullRequest.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../base/common/codicons.js'; +import { themeColorFromId, ThemeIcon } from '../../base/common/themables.js'; + +export type ChatPullRequestState = 'open' | 'closed' | 'merged' | 'draft'; + +/** + * Additional live status used to refine the icon of an open pull request. + */ +export interface IPullRequestIconStatus { + /** Whether the pull request has merge conflicts. */ + readonly hasMergeConflicts?: boolean; + /** Whether the pull request has at least one failing CI check. */ + readonly hasFailingChecks?: boolean; + /** Whether the pull request has at least one unresolved review comment thread. */ + readonly hasUnresolvedComments?: boolean; +} + +/** + * Computes the shared pull request glyph and state color used by chat/session pills. + */ +export function computePullRequestIcon(state: ChatPullRequestState, status?: IPullRequestIconStatus): ThemeIcon { + switch (state) { + case 'merged': + return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; + case 'closed': + return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; + case 'draft': + return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; + case 'open': + if (status?.hasMergeConflicts || status?.hasFailingChecks) { + return { ...Codicon.gitPullRequestError, color: themeColorFromId('charts.orange') }; + } + if (status?.hasUnresolvedComments) { + return { ...Codicon.gitPullRequestComment, color: themeColorFromId('charts.green') }; + } + return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; + } +} + +const pullRequestIconPriority = new Map([ + [Codicon.gitPullRequestError.id, 6], + [Codicon.gitPullRequestComment.id, 5], + [Codicon.gitPullRequest.id, 4], + [Codicon.gitPullRequestDraft.id, 3], + [Codicon.gitPullRequestDone.id, 2], + [Codicon.gitPullRequestClosed.id, 1], +]); + +/** Returns the most important status icon across a set of pull requests. */ +export function getHighestPriorityPullRequestIcon(icons: readonly (ThemeIcon | undefined)[]): ThemeIcon | undefined { + let result: ThemeIcon | undefined; + let resultPriority = -1; + for (const icon of icons) { + if (!icon) { + continue; + } + const priority = pullRequestIconPriority.get(icon.id) ?? 0; + if (priority > resultPriority) { + result = icon; + resultPriority = priority; + } + } + return result; +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 59e4716c4eba93..a86a086ef99461 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -21,7 +21,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { isStickyPromptHeaderShown } from '../promptTimeline/promptTimelineWidgetContrib.js'; import { FocusAgentSessionsAction } from '../agentSessions/agentSessionsActions.js'; import { AGENT_SESSION_RENAME_ACTION_ID } from '../agentSessions/agentSessions.js'; -import { IChatWidgetService } from '../chat.js'; +import { IChatWidgetService, isIChatResourceViewContext } from '../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../chatEditing/chatEditingActions.js'; export class PanelChatAccessibilityHelp implements IAccessibleViewImplementation { @@ -64,7 +64,7 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false, sessionStatusPillsSupported: boolean = type === 'panelChat' || type === 'agentView'): string { const content = []; if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files. Focus an additions and deletions label and press Enter or Space to open the changes in a diff editor.')); @@ -82,6 +82,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.openAgentHostFolderPicker', 'When starting an agent session in a multi-root workspace, you can choose which root folder it runs in by invoking the Folder command{0}, then selecting a folder from the list.', '')); content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); } + if (sessionStatusPillsSupported) { + content.push(localize('chat.sessionStatusPills', 'When session status pills appear above the input, use Tab to focus the toolbar, then use the left and right arrow keys to move between pills. Press Enter or Space to activate a pill. Open the context menu{0} to choose which optional pills are visible.', '')); + } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position and selected size are shared across chats and windows and remembered after you restart.', '')); if (supportsFileReferences) { @@ -206,7 +209,8 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi const cachedPosition = inputEditor.getPosition(); inputEditor.getSupportedActions(); - const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService)); + const isInlineChat = isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isInlineChat; + const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService), !widget.rendersInputOnTop && !isInlineChat); return new AccessibleContentProvider( type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index bf4571c1ec3ad0..c5fce8067b5bf0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -11,7 +11,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { AgentHostMcpServers, AgentHostMcpServersConfigKey } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -569,7 +569,11 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus const provisionalSession = this._provisionalSessionService.get(sessionResource); if (provisionalSession) { // Provisional (untitled) sessions are always backed by the ambient host. - return { connection: this._connectionsService.ambientConnection, backendSession: provisionalSession }; + return { + connection: this._connectionsService.ambientConnection, + connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, + backendSession: provisionalSession, + }; } if (isUntitledChatSession(sessionResource)) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts index 45f723b5be3fd1..732b3311a1b87b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -32,7 +32,7 @@ import { } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; -import { IChatResponseFileChangesProvider, IChatResponseFileEdit } from '../../chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesProvider, IChatResponseFileEdit } from '../../chatResponseFileChangesService.js'; const SUBSCRIPTION_OWNER = 'AgentHostResponseFileChangesProvider'; const REQUEST_CACHE_CAPACITY = 1000; @@ -43,6 +43,13 @@ const REQUEST_CACHE_CAPACITY = 1000; */ type TurnDiffSource = 'unsupported' | 'changeset' | 'authoritativeEmpty' | 'response' | 'branchFallback' | 'retained'; +interface IResponseFileEdits { + readonly diffs: readonly IChatResponseFileEdit[]; + readonly hasValidEdits: boolean; +} + +const EMPTY_RESPONSE_FILE_EDITS: IResponseFileEdits = { diffs: [], hasValidEdits: false }; + function uriArrayEquals(a: readonly URI[], b: readonly URI[]): boolean { return a.length === b.length && a.every((uri, index) => isEqual(uri, b[index])); } @@ -63,6 +70,36 @@ function getToolCallFileEdits(toolCall: ToolCallState): ISessionFileDiff[] { return edits; } +/** Maps one Agent Host changeset file into the diff shape used by chat editors. */ +export function agentHostChangesetFileToEntryDiff(file: ChangesetFile, connectionAuthority: string): IEditSessionEntryDiff | undefined { + const normalized = normalizeFileEdit(file.edit); + if (!normalized) { + return undefined; + } + + const modifiedURI = toAgentHostUri(normalized.resource, connectionAuthority); + const originalURI = normalized.beforeContentUri + ? toAgentHostContentUri(normalized.beforeContentUri, connectionAuthority) + : modifiedURI; + const modifiedSnapshotURI = normalized.afterContentUri + ? toAgentHostContentUri(normalized.afterContentUri, connectionAuthority) + : undefined; + + return { + originalURI, + modifiedURI, + modifiedSnapshotURI, + isCreated: normalized.kind === FileEditKind.Create, + isDeleted: normalized.kind === FileEditKind.Delete, + added: file.edit.diff?.added ?? 0, + removed: file.edit.diff?.removed ?? 0, + quitEarly: false, + identical: false, + isFinal: true, + isBusy: false, + }; +} + /** * Supplies the chat "Changed N files" summary for agent host responses from the * authoritative per-turn changeset the host computes server-side (the same @@ -122,7 +159,8 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const key = `${backendSession.toString()}\0${backendChat?.toString() ?? ''}\0${requestId}`; let obs = this._perRequestFileEdits.get(key); if (!obs) { - obs = this._createFileEditDiffsObservable(backendSession, backendChat, requestId); + const fileEdits = this._createFileEditDiffsObservable(backendSession, backendChat, requestId); + obs = derived(reader => fileEdits.read(reader).diffs); this._perRequestFileEdits.set(key, obs); } return obs; @@ -176,7 +214,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const changesetState = turnUri ? changesetStateObs.read(reader).read(reader) : undefined; const changeset = changesetState instanceof Error ? undefined : changesetState; const changesetDiffs = changeset?.files - .map(file => this._changesetFileToEntryDiff(file)) + .map(file => agentHostChangesetFileToEntryDiff(file, this._connectionAuthority)) .filter(isDefined); // A non-empty per-turn changeset is always authoritative (e.g. a turn // added after migration, which does have checkpoints), so it takes @@ -199,12 +237,12 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements return select('unsupported', retained); } if (changeset?.status === ChangesetStatus.Ready && retained.length === 0) { - return select('authoritativeEmpty', [], changeset.status); + return select('authoritativeEmpty', AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, changeset.status); } - const responseDiffs = responseFileEditsObs.read(reader); - return responseDiffs.length - ? select('response', responseDiffs, changeset?.status) + const responseFileEdits = responseFileEditsObs.read(reader); + return responseFileEdits.hasValidEdits + ? select('response', responseFileEdits.diffs.length > 0 ? responseFileEdits.diffs : AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, changeset?.status) : select('retained', retained, changeset?.status); }); } @@ -244,12 +282,12 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const state = branchChangesetStateObs.read(reader).read(reader); const changeset = state instanceof Error ? undefined : state; return changeset?.files - .map(file => this._changesetFileToEntryDiff(file)) + .map(file => agentHostChangesetFileToEntryDiff(file, this._connectionAuthority)) .filter(isDefined) ?? []; }); } - private _createFileEditDiffsObservable(backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { + private _createFileEditDiffsObservable(backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { const sessionStateObs = this._subscribe(StateComponents.Session, constObservable(backendSession)); const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); @@ -301,7 +339,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements return this._responsePartsToEntryDiffs(turn.responseParts, workspaceRoots); } } - return []; + return EMPTY_RESPONSE_FILE_EDITS; }); } @@ -323,8 +361,9 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements }); } - private _responsePartsToEntryDiffs(responseParts: readonly ResponsePart[], workspaceRoots: readonly URI[]): IChatResponseFileEdit[] { + private _responsePartsToEntryDiffs(responseParts: readonly ResponsePart[], workspaceRoots: readonly URI[]): IResponseFileEdits { const byUri = new Map(); + let hasValidEdits = false; for (const responsePart of responseParts) { if (responsePart.kind !== ResponsePartKind.ToolCall) { continue; @@ -334,27 +373,35 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements if (!diff) { continue; } + hasValidEdits = true; const key = getComparisonKey(diff.modifiedURI); const existing = byUri.get(key); if (existing) { existing.added += diff.added; existing.removed += diff.removed; + existing.modifiedURI = diff.modifiedURI; + existing.modifiedSnapshotURI = diff.modifiedSnapshotURI; + existing.isDeleted = diff.isDeleted; + // A file created and then deleted within the turn has no net diff to present. + if (existing.isCreated && existing.isDeleted) { + byUri.delete(key); + } } else { byUri.set(key, diff); } } } - return [...byUri.values()]; + return { diffs: [...byUri.values()], hasValidEdits }; } private _fileEditToEntryDiff(fileEdit: ISessionFileDiff, workspaceRoots: readonly URI[]): IChatResponseFileEdit | undefined { const normalized = normalizeFileEdit(fileEdit); - if (!normalized || !normalized.afterUri) { + if (!normalized) { return undefined; } - const afterUri = normalized.afterUri; + const resource = normalized.resource; - const modifiedURI = toAgentHostUri(afterUri, this._connectionAuthority); + const modifiedURI = toAgentHostUri(resource, this._connectionAuthority); const originalURI = normalized.kind === FileEditKind.Create || !normalized.beforeContentUri ? modifiedURI : toAgentHostContentUri(normalized.beforeContentUri, this._connectionAuthority); @@ -366,52 +413,16 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements originalURI, modifiedURI, modifiedSnapshotURI, + isCreated: normalized.kind === FileEditKind.Create, + isDeleted: normalized.kind === FileEditKind.Delete, added: fileEdit.diff?.added ?? 0, removed: fileEdit.diff?.removed ?? 0, quitEarly: false, identical: false, isFinal: true, isBusy: false, - isOutsideWorkspace: !workspaceRoots.some(root => isEqualOrParent(afterUri, root)), + isOutsideWorkspace: !workspaceRoots.some(root => isEqualOrParent(resource, root)), }; } - private _changesetFileToEntryDiff(file: ChangesetFile): IEditSessionEntryDiff | undefined { - const normalized = normalizeFileEdit(file.edit); - if (!normalized) { - return undefined; - } - - const modifiedURI = toAgentHostUri(normalized.resource, this._connectionAuthority); - // For creates there is no before-content; fall back to the modified URI - // so the entry still resolves. The collapsed summary uses the - // server-provided counts below, so its +/- numbers stay correct - // regardless; only an explicitly-opened diff of a created file shows no - // delta. - const originalURI = normalized.beforeContentUri - ? toAgentHostContentUri(normalized.beforeContentUri, this._connectionAuthority) - : modifiedURI; - - // The frozen after-turn snapshot, when the changeset provides one. Lets - // consumers show this turn's diff (before-snapshot -> after-snapshot) - // rather than before-snapshot -> live file (which includes later turns). - // Distinct from the checkpoint-ref readability fix (#323932): that made - // these blobs readable; this line decides *which* snapshot to diff against. - const modifiedSnapshotURI = normalized.afterContentUri - ? toAgentHostContentUri(normalized.afterContentUri, this._connectionAuthority) - : undefined; - - return { - originalURI, - modifiedURI, - modifiedSnapshotURI, - isDeleted: normalized.kind === FileEditKind.Delete, - added: file.edit.diff?.added ?? 0, - removed: file.edit.diff?.removed ?? 0, - quitEarly: false, - identical: false, - isFinal: true, - isBusy: false, - }; - } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts new file mode 100644 index 00000000000000..ae8797faaea5a7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -0,0 +1,551 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../../../base/browser/dom.js'; +import { toAction } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, derived, derivedObservableWithCache, derivedOpts, observableFromEvent, observableSignal, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { isDefined } from '../../../../../../base/common/types.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { resolveChangesetUriTemplate, selectDefaultChangeset, type DefaultChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { ISessionArtifact, isGitHubArtifactLink, readSessionArtifacts, SessionArtifactType } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillEntries, getChatPillResourceLocation, IChatPillEntry, IChatPillSection, type ChatPillsCompactMode } from '../../../../../browser/chatPills.js'; +import { chatChangesStatsEqual, EMPTY_CHAT_CHANGES_STATS, IChatChangesStats } from '../../../../../browser/chatChangesPill.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { computePullRequestIcon, getHighestPriorityPullRequestIcon } from '../../../../../common/chatPullRequest.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { chatPersistentContentVisibleClass, type ChatWidget } from '../../widget/chatWidget.js'; +import { observeTurnStatusPillsEnabled, openChatTurnFile, previewKind } from '../../widget/chatTurnPills.js'; +import { openChatFileChanges } from '../../editorChatResponseFileChangesService.js'; +import { ChatInputPills, StandardChatInputPillSources } from '../../chatInputPills.js'; +import { agentHostChangesetFileToEntryDiff } from './agentHostResponseFileChanges.js'; + +const offeredPillKinds: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Artifacts, + SessionChatPillKind.References, + SessionChatPillKind.Browsers, +]; + +const artifactIcons: ReadonlyMap = new Map([ + [SessionArtifactType.PullRequest, Codicon.gitPullRequest], + [SessionArtifactType.Issue, Codicon.issues], + [SessionArtifactType.Commit, Codicon.gitCommit], + [SessionArtifactType.Website, Codicon.globe], + [SessionArtifactType.Resource, Codicon.link], +]); + +const artifactSectionOrder: readonly { readonly type: SessionArtifactType; readonly title: string }[] = [ + { type: SessionArtifactType.PullRequest, title: localize('agentHostSessionPills.artifacts.pullRequests', "Pull Requests") }, + { type: SessionArtifactType.Issue, title: localize('agentHostSessionPills.artifacts.issues', "Issues") }, + { type: SessionArtifactType.Commit, title: localize('agentHostSessionPills.artifacts.commits', "Commits") }, + { type: SessionArtifactType.Website, title: localize('agentHostSessionPills.artifacts.websites', "Websites") }, + { type: SessionArtifactType.File, title: localize('agentHostSessionPills.artifacts.files', "Files") }, + { type: SessionArtifactType.Resource, title: localize('agentHostSessionPills.artifacts.resources', "Resources") }, +]; + +export interface IAgentHostSessionPillMetadata { + readonly pullRequestUrls: readonly string[]; + readonly issueUrls: readonly string[]; + readonly artifacts: readonly ISessionArtifact[]; + readonly references: readonly ISessionArtifact[]; +} + +function linkKey(link: string): string { + return link.replace(/\/+$/, '').toLowerCase(); +} + +function dedupeLinks(...groups: readonly (readonly string[] | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const group of groups) { + for (const link of group ?? []) { + const key = linkKey(link); + if (!seen.has(key)) { + seen.add(key); + result.push(link); + } + } + } + return result; +} + +function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { + return first === second || (first.size === second.size && [...first].every(value => second.has(value))); +} + +function isPromotedArtifact(artifact: ISessionArtifact, type: SessionArtifactType): artifact is ISessionArtifact & { readonly link: string } { + return artifact.isArtifact + && artifact.type === type + && artifact.isGitHub === true + && typeof artifact.link === 'string' + && isGitHubArtifactLink(artifact.link); +} + +/** Partitions Agent Host metadata into dedicated GitHub, artifact, and reference pills. */ +export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undefined): IAgentHostSessionPillMetadata { + const entries = readSessionArtifacts(meta); + const github = readSessionGitHubState(meta); + const artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)).map(entry => entry.link); + const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)).map(entry => entry.link); + const pullRequestUrls = dedupeLinks(getSessionRelatedPullRequestUrls(github), artifactPullRequests); + const issueUrls = dedupeLinks(artifactIssues); + const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey)); + const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link))); + return { + pullRequestUrls, + issueUrls, + artifacts: remaining.filter(entry => entry.isArtifact), + references: remaining.filter(entry => !entry.isArtifact), + }; +} + +/** Resolves the session-wide changeset represented by the workbench Changes pill. */ +export function resolveAgentHostSessionChangeset( + backendSession: URI, + changesets: readonly Changeset[] | undefined, + defaultKind?: DefaultChangesetKind, +): { readonly changeset: Changeset; readonly resource: URI } | undefined { + const staticChangesets = changesets?.filter(changeset => !changeset.uriTemplate.includes('{')) ?? []; + const changeset = selectDefaultChangeset(staticChangesets, defaultKind); + const resource = changeset ? parseUri(resolveChangesetUriTemplate(backendSession.toString(), changeset.uriTemplate)) : undefined; + return changeset && resource ? { changeset, resource } : undefined; +} + +/** Returns the workbench chat resources whose browsers belong in the current chat's pill. */ +export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: Pick | undefined): ReadonlySet { + const ownerIds = new Set([sessionResource.toString()]); + if (!state) { + return ownerIds; + } + + const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + const currentChatResource = parseUri(explicitChatResource ?? getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()); + if (!currentChatResource) { + return ownerIds; + } + + for (const chat of state.chats) { + const parentChatResource = chat.origin?.kind === ChatOriginKind.Tool ? parseUri(chat.origin.chat) : undefined; + const parsedChat = parseChatUri(chat.resource); + if (!parentChatResource || !isEqual(parentChatResource, currentChatResource) || !parsedChat) { + continue; + } + + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: null }).toString()); + const query = new URLSearchParams(sessionResource.query); + query.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, chat.resource); + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: query.toString() }).toString()); + } + return ownerIds; +} + +function resolutionEquals(first: IAgentHostSessionResolution | undefined, second: IAgentHostSessionResolution | undefined): boolean { + return first === second || (!!first && !!second + && first.connection === second.connection + && first.connectionAuthority === second.connectionAuthority + && first.defaultChangesetKind === second.defaultChangesetKind + && isEqual(first.backendSession, second.backendSession)); +} + +function changesetTargetEquals( + first: { readonly changeset: Changeset; readonly resource: URI } | undefined, + second: { readonly changeset: Changeset; readonly resource: URI } | undefined, +): boolean { + return first === second || (!!first && !!second + && first.changeset.changeKind === second.changeset.changeKind + && first.changeset.label === second.changeset.label + && first.changeset.uriTemplate === second.changeset.uriTemplate + && isEqual(first.resource, second.resource)); +} + +function parseUri(value: string | undefined): URI | undefined { + if (!value) { + return undefined; + } + try { + return URI.parse(value, true); + } catch { + return undefined; + } +} + +function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string { + const resource = parseUri(link); + const number = resource ? githubReferenceNumber(resource, kind) : undefined; + if (kind === 'pullRequest') { + return number + ? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number) + : localize('agentHostSessionPills.pullRequest', "Pull Request"); + } + return number + ? localize('agentHostSessionPills.issue.number', "Issue #{0}", number) + : localize('agentHostSessionPills.issue', "Issue"); +} + +function githubReferenceNumber(resource: URI, kind: 'pullRequest' | 'issue'): string | undefined { + const segment = kind === 'pullRequest' ? 'pull' : 'issues'; + return new RegExp(`/${segment}/(?\\d+)(?:/|$)`).exec(resource.path)?.groups?.number; +} + +function websiteKey(url: string): string | undefined { + const parsed = URL.parse(url); + if (!parsed) { + return undefined; + } + const path = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${path}${parsed.search}${parsed.hash}`; +} + +/** Adds Agent Host session metadata pills to a workbench chat input. */ +export class AgentHostSessionInputPills extends Disposable { + + private readonly _browserChanged = observableSignal(this); + private readonly _browserListeners = this._register(new MutableDisposable()); + + constructor( + private readonly _widget: ChatWidget, + compact: ChatPillsCompactMode, + @IAgentHostConnectionsService connectionsService: IAgentHostConnectionsService, + @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, + @IClipboardService private readonly _clipboardService: IClipboardService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService private readonly _openerService: IOpenerService, + @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, + ) { + super(); + + const pillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); + const sessionResource = observableFromEvent(this, this._widget.onDidChangeViewModel, () => this._widget.viewModel?.sessionResource); + const sessionResolutionChanged = observableSignalFromEvent(this, connectionsService.onDidChangeSessionResolution); + const resolution = derivedOpts({ owner: this, equalsFn: resolutionEquals }, reader => { + sessionResolutionChanged.read(reader); + const resource = sessionResource.read(reader); + return resource ? connectionsService.resolveSessionResource(resource) : undefined; + }); + const sessionStateSource = derived(this, reader => { + const current = resolution.read(reader); + if (!current || !pillsEnabled.read(reader)) { + return constObservable(undefined); + } + const subscription = reader.store.add(current.connection.getSubscription(StateComponents.Session, current.backendSession, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const sessionState = derived(this, reader => sessionStateSource.read(reader).read(reader)); + const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? resolveAgentHostSessionChangeset(currentResolution.backendSession, sessionState.read(reader)?.changesets, currentResolution.defaultChangesetKind) + : undefined; + }); + const changesetStateSource = derived(this, reader => { + const currentResolution = resolution.read(reader); + const resource = changesetTarget.read(reader)?.resource; + if (!currentResolution || !resource) { + return constObservable(undefined); + } + const subscription = reader.store.add(currentResolution.connection.getSubscription(StateComponents.Changeset, resource, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const changesetFiles = derivedObservableWithCache<{ readonly connectionAuthority: string; readonly resource: URI; readonly files: ChangesetState['files'] } | undefined>(this, (reader, lastValue) => { + const currentResolution = resolution.read(reader); + const target = changesetTarget.read(reader); + if (!currentResolution || !target) { + return undefined; + } + const state = changesetStateSource.read(reader).read(reader); + if (!state) { + return lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource) ? lastValue : undefined; + } + if (state.status !== ChangesetStatus.Ready && lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource)) { + return lastValue; + } + return { connectionAuthority: currentResolution.connectionAuthority, resource: target.resource, files: state.files }; + }); + const changes = derived(this, reader => { + const currentResolution = resolution.read(reader); + const files = changesetFiles.read(reader)?.files; + if (!currentResolution || !files) { + return []; + } + return files + .map(file => agentHostChangesetFileToEntryDiff(file, currentResolution.connectionAuthority)) + .filter(isDefined); + }); + const changeStats = derivedOpts({ owner: this, equalsFn: chatChangesStatsEqual }, reader => { + const diffs = changes.read(reader); + return diffs.length === 0 + ? EMPTY_CHAT_CHANGES_STATS + : { + files: diffs.length, + insertions: diffs.reduce((total, diff) => total + diff.added, 0), + deletions: diffs.reduce((total, diff) => total + diff.removed, 0), + }; + }); + const metadata = derived(this, reader => getAgentHostSessionPillMetadata(sessionState.read(reader)?._meta)); + const gitHubState = derived(this, reader => readSessionGitHubState(sessionState.read(reader)?._meta)); + + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); + this._refreshBrowserListeners(); + const browserInputs = derived(this, reader => { + this._browserChanged.read(reader); + const resource = sessionResource.read(reader); + if (!resource || !resolution.read(reader) || !pillsEnabled.read(reader)) { + return []; + } + const ownerIds = getAgentHostSessionBrowserOwnerIds(resource, sessionState.read(reader)); + return [...this._browserViewService.getKnownBrowserViews().values()] + .filter(input => input.model?.owner.type === 'agent' && ownerIds.has(input.model.owner.sessionId)); + }); + const browserUrls = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { + return visibility.isVisible(SessionChatPillKind.Browsers, reader) + ? new Set(browserInputs.read(reader).map(input => input.url).filter(isDefined)) + : new Set(); + }); + + const pullRequestSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).pullRequestUrls, 'pullRequest', gitHubState.read(reader))); + const pullRequestIcon = derived(this, reader => { + const icons = getChatPillEntries(pullRequestSections.read(reader)).map(entry => entry.icon); + return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon('open'); + }); + const issueSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).issueUrls, 'issue')); + const artifactSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).artifacts, browserUrls.read(reader), currentResolution) + : []; + }); + const referenceSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).references, browserUrls.read(reader), currentResolution) + : []; + }); + const browserSections = derived(this, reader => { + const entries = browserInputs.read(reader).map(input => this._browserEntry(input, sessionResource.read(reader))); + return entries.length > 0 ? [{ title: localize('agentHostSessionPills.browsers.section', "Browsers"), entries }] : []; + }); + + const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { + changes: { + stats: changeStats, + label: derived(this, reader => changesetTarget.read(reader)?.changeset.label ?? localize('agentHostSessionPills.changes', "Changes")), + open: () => this._openChanges(changesetTarget.get()?.changeset.label ?? localize('agentHostSessionPills.changesEditor', "Session Changes"), changes.get()), + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestIcon }, + issues: { sections: issueSections }, + artifacts: { sections: artifactSections }, + references: { sections: referenceSections }, + browsers: { sections: browserSections }, + }, offeredPillKinds)); + const inputPills = this._register(instantiationService.createInstance(ChatInputPills, this._widget.inputPart.persistentContentContainerElement, { + debugName: 'AgentHostSessionInputPills.content', + compact, + targetWindow: getWindow(this._widget.inputPart.persistentContentContainerElement), + enabled: pillsEnabled, + sources: constObservable(sources.sources), + offeredKinds: offeredPillKinds, + ariaLabel: localize('agentHostSessionPills.ariaLabel', "Session status"), + focusFallback: () => this._widget.focusInput(), + })); + inputPills.element.classList.add('agent-host-session-input-pills'); + + this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ + onDidChange: inputPills.onDidChange, + getElements: () => inputPills.getPillElements(), + })); + const updateVisibility = (visible: boolean) => { + this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); + this._widget.setPersistentContentHeight(visible ? CHAT_INPUT_PILLS_ROW_HEIGHT : undefined); + }; + this._register(inputPills.onDidChangeVisibility(updateVisibility)); + updateVisibility(inputPills.visible); + } + + private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType): readonly IChatPillSection[] { + const entries = links.map(link => { + const resource = parseUri(link); + if (!resource) { + return undefined; + } + const number = githubReferenceNumber(resource, kind); + const label = referenceLabel(link, kind); + const pullRequestState = kind === 'pullRequest' + && gitHubState?.pullRequestState + && gitHubState.pullRequestStateUrl + && linkKey(gitHubState.pullRequestStateUrl) === linkKey(link) + ? gitHubState.pullRequestState + : 'open'; + return { + id: linkKey(link), + label, + ...(kind === 'pullRequest' && number ? { pillLabel: `#${number}` } : {}), + icon: kind === 'pullRequest' ? computePullRequestIcon(pullRequestState) : Codicon.issues, + toolbarActions: [toAction({ + id: `chatInputPills.copy.${kind}.${linkKey(link)}`, + label: kind === 'pullRequest' + ? localize('agentHostSessionPills.copyPullRequest', "Copy Pull Request URL") + : localize('agentHostSessionPills.copyIssue', "Copy Issue URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(resource.toString(true)), + })], + ...getChatPillResourceLocation(resource, label), + open: () => this._openExternal(resource), + } satisfies IChatPillEntry; + }).filter(isDefined); + const title = kind === 'pullRequest' + ? localize('agentHostSessionPills.pullRequests.section', "Pull Requests") + : localize('agentHostSessionPills.issues.section', "Issues"); + return entries.length > 0 ? [{ title, entries }] : []; + } + + private _buildArtifactSections(entries: readonly ISessionArtifact[], browserUrls: ReadonlySet, resolution: IAgentHostSessionResolution): readonly IChatPillSection[] { + const browserKeys = new Set([...browserUrls].map(websiteKey).filter(isDefined)); + const entriesByType = new Map(); + for (const artifact of entries) { + if (artifact.type === SessionArtifactType.Website && artifact.link) { + const key = websiteKey(artifact.link); + if (key && browserKeys.has(key)) { + continue; + } + } + const entry = this._artifactEntry(artifact, resolution); + if (entry) { + const typeEntries = entriesByType.get(artifact.type) ?? []; + typeEntries.push(entry); + entriesByType.set(artifact.type, typeEntries); + } + } + return artifactSectionOrder.flatMap(({ type, title }) => { + const sectionEntries = entriesByType.get(type); + return sectionEntries?.length ? [{ title, entries: sectionEntries }] : []; + }); + } + + private _artifactEntry(artifact: ISessionArtifact, resolution: IAgentHostSessionResolution): IChatPillEntry | undefined { + if (artifact.type === SessionArtifactType.File || artifact.type === SessionArtifactType.Resource) { + const artifactResource = parseUri(artifact.uri); + if (!artifactResource) { + return undefined; + } + const resource = artifact.type === SessionArtifactType.File + ? toAgentHostUri(artifactResource, resolution.connectionAuthority) + : artifactResource; + const label = artifact.type === SessionArtifactType.File ? basename(resource) : artifact.label; + return { + id: artifact.id, + label, + ...(artifact.type === SessionArtifactType.File ? { resource } : { icon: Codicon.link }), + ...getChatPillResourceLocation(resource, label), + open: () => this._openResource(resource), + }; + } + + const link = parseUri(artifact.link); + const icon = artifactIcons.get(artifact.type) ?? Codicon.archive; + if (link) { + const copyAction = artifact.type === SessionArtifactType.Commit && artifact.commitHash + ? [toAction({ + id: 'chat.agentHost.sessionPills.copyCommitHash', + label: localize('agentHostSessionPills.copyCommitHash', "Copy Commit Hash"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(artifact.commitHash!), + })] + : undefined; + return { + id: artifact.id, + label: artifact.label, + icon, + ...(copyAction ? { toolbarActions: copyAction } : {}), + ...getChatPillResourceLocation(link, artifact.label), + open: () => this._openExternal(link), + }; + } + if (artifact.type === SessionArtifactType.Commit && artifact.commitHash) { + return { + id: artifact.id, + label: artifact.label, + icon, + ariaLabel: localize('agentHostSessionPills.copyCommit', "Copy commit hash for {0}", artifact.label), + tooltip: artifact.commitHash, + open: () => { void this._clipboardService.writeText(artifact.commitHash!); }, + }; + } + return undefined; + } + + private _browserEntry(input: BrowserEditorInput, sessionResource: URI | undefined): IChatPillEntry { + const label = input.title?.trim() || localize('agentHostSessionPills.browser', "Browser"); + return { + id: input.id, + label, + icon: Codicon.globe, + open: () => { void this._openBrowser(input, sessionResource); }, + }; + } + + private async _openBrowser(input: BrowserEditorInput, sessionResource: URI | undefined): Promise { + const url = input.url; + const shared = url + ? [...this._browserViewService.getContextualBrowserViews({ activeSessionId: sessionResource?.toString() }).values()] + .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)) + : []; + const target = input.model?.sharingState === BrowserViewSharingState.Shared || !url + ? input + : shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; + const existing = this._editorService.findEditors(target.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); + const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); + await this._editorService.openEditor(target, undefined, targetGroup); + } + + private _openChanges(label: string, diffs: readonly IEditSessionEntryDiff[]): void { + if (diffs.length > 0) { + openChatFileChanges(this._editorService, label, diffs); + } + } + + private _openExternal(resource: URI): void { + void this._openerService.open(resource, { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }); + } + + private _openResource(resource: URI): void { + const kind = previewKind(resource); + if (kind) { + void openChatTurnFile({ uri: resource, kind, created: false }, this._openerService, this._configurationService); + return; + } + void this._openerService.open(resource, { fromUserGesture: true }); + } + + private _refreshBrowserListeners(): void { + const store = new DisposableStore(); + this._browserListeners.value = store; + for (const input of this._browserViewService.getKnownBrowserViews().values()) { + store.add(input.onDidChangeLabel(() => this._browserChanged.trigger(undefined))); + } + this._browserChanged.trigger(undefined); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 33f54d64db9a94..03d38ce9451e57 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -71,6 +71,7 @@ import { ILanguageModelStatsService, LanguageModelStatsService } from '../common import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { ISessionChatPillVisibilityService, SessionChatPillVisibility } from '../common/sessionChatPills.js'; import { AgentPluginDiscoveryPriority, IAgentPluginService, agentPluginDiscoveryRegistry } from '../common/plugins/agentPluginService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig, isTildePath } from '../common/promptSyntax/config/config.js'; @@ -1073,7 +1074,7 @@ configurationRegistry.registerConfiguration({ deprecationMessage: nls.localize('chat.turnStatusPills.objectDeprecated', "The per-pill object form is deprecated. Use a boolean value instead."), }, ], - markdownDescription: nls.localize('chat.turnStatusPills', "Controls whether agent status pills are shown above the chat input while a turn is in progress and inside the completed response. Only applies to agent sessions."), + markdownDescription: nls.localize('chat.turnStatusPills', "Controls whether agent status pills are shown above the chat input and inside completed responses. Only applies to agent sessions."), default: true, }, [mcpAccessConfig]: { @@ -3282,6 +3283,7 @@ registerSingleton(IVoiceCodeTranscriptionClient, VoiceCodeTranscriptionClient, I registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); +registerSingleton(ISessionChatPillVisibilityService, SessionChatPillVisibility, InstantiationType.Delayed); registerSingleton(IChatPasteTargetService, ChatPasteTargetService, InstantiationType.Delayed); registerSingleton(IChatSideChatService, ChatSideChatService, InstantiationType.Delayed); registerSingleton(IChatRequestOriginService, ChatRequestOriginService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 594743c9fc7d22..b1dbbc19a7a1a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -450,6 +450,8 @@ export interface IChatWidget { setInput(query?: string): void; getInput(): string; refreshParsedInput(): void; + /** Floats persistent input content and reserves matching space below the transcript. */ + setPersistentContentHeight(height: number | undefined): void; logInputHistory(): void; acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise; getSelectedModelRequestOptions(): Pick; diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPills.ts b/src/vs/workbench/contrib/chat/browser/chatInputPills.ts new file mode 100644 index 00000000000000..ca5cd44c7c3a64 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputPills.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import type { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Action, Separator, toAction, type IAction, type IActionRunner } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import type { ThemeIcon } from '../../../../base/common/themables.js'; +import type { CodeWindow } from '../../../../base/browser/window.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../browser/labels.js'; +import { ChatChangesPillActionViewItem, type IChatChangesStats } from '../../../browser/chatChangesPill.js'; +import { ChatPillsRow, ChatPillsWidget, getChatPillEntries, type ChatPillsCompactMode, type IChatPill, type IChatPillSection } from '../../../browser/chatPills.js'; +import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../browser/chatDropdownPill.js'; +import { getSessionChatPillLabel, getSessionChatPillMenu, ISessionChatPillVisibilityService, type ISessionChatPillMenuEntry, SessionChatPillKind } from '../common/sessionChatPills.js'; +import { chatArtifactPillOptions } from './widget/chatTurnPills.js'; +import { sessionBrowsersPillOptions, sessionCustomizationsPillOptions, sessionIssuesPillOptions, sessionPullRequestsPillOptions, sessionReferencesPillOptions, sessionSubagentsPillOptions } from './sessionChatPillOptions.js'; + +export interface IChatInputPillSource { + readonly kind?: SessionChatPillKind; + readonly hasData: IObservable; + readonly pill: IObservable; +} + +export interface IChatInputPillsOptions { + readonly debugName: string; + readonly compact: ChatPillsCompactMode; + readonly targetWindow?: CodeWindow; + readonly enabled: IObservable; + readonly sources: IObservable; + readonly offeredKinds: readonly SessionChatPillKind[]; + readonly ariaLabel?: string; + readonly context?: IObservable; + readonly actionRunner?: IActionRunner; + readonly focusFallback?: () => void; +} + +export interface IStandardChatInputPillSections { + readonly sections: IObservable; + readonly icon?: ThemeIcon | IObservable; +} + +export interface IStandardChatInputPillsData { + readonly changes?: { + readonly stats: IObservable; + readonly label: IObservable; + open(): void; + }; + readonly pullRequests?: IStandardChatInputPillSections; + readonly issues?: IStandardChatInputPillSections; + readonly artifacts?: IStandardChatInputPillSections; + readonly references?: IStandardChatInputPillSections; + readonly customizations?: IStandardChatInputPillSections; + readonly browsers?: IStandardChatInputPillSections; + readonly subagents?: IStandardChatInputPillSections; +} + +function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { + return first === second || (first.size === second.size && [...first].every(value => second.has(value))); +} + +/** Creates a source backed by one section/dropdown pill. */ +export function createChatSectionPillSource( + kind: SessionChatPillKind, + action: IChatPill['action'], + sections: IObservable, + options: IChatDropdownPillOptions, + resourceLabels: ResourceLabels, + instantiationService: IInstantiationService, +): IChatInputPillSource { + return { + kind, + hasData: derived(reader => getChatPillEntries(sections.read(reader)).length > 0), + pill: createChatSectionPill(action, sections, options, resourceLabels, instantiationService), + }; +} + +/** Builds the canonical pill components and ordering from surface-specific data adapters. */ +export class StandardChatInputPillSources extends Disposable { + readonly sources: readonly IChatInputPillSource[]; + + constructor( + data: IStandardChatInputPillsData, + offeredKinds: readonly SessionChatPillKind[], + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const offered = new Set(offeredKinds); + const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const sources: IChatInputPillSource[] = []; + if (data.changes && offered.has(SessionChatPillKind.Changes)) { + const changes = data.changes; + const action = this._register(new Action('chatInputPills.changes', changes.label.get(), undefined, true, () => changes.open())); + this._register(autorun(reader => { + const label = changes.label.read(reader); + action.label = label; + action.tooltip = localize('chatInputPills.viewChanges', "View {0}", label); + })); + const pill: IChatPill = { + action, + createActionViewItem: (options: IActionViewItemOptions) => new ChatChangesPillActionViewItem(action, options, changes.stats, instantiationService), + }; + sources.push({ + kind: SessionChatPillKind.Changes, + hasData: derived(reader => changes.stats.read(reader).files > 0), + pill: constObservable(pill), + }); + } + + const addSections = (kind: SessionChatPillKind, source: IStandardChatInputPillSections | undefined, options: IChatDropdownPillOptions) => { + if (!source || !offered.has(kind)) { + return; + } + const action = this._register(new Action(`chatInputPills.${kind}`, getSessionChatPillLabel(kind))); + sources.push(createChatSectionPillSource(kind, action, source.sections, source.icon ? { ...options, icon: source.icon } : options, resourceLabels, instantiationService)); + }; + addSections(SessionChatPillKind.PullRequests, data.pullRequests, sessionPullRequestsPillOptions); + addSections(SessionChatPillKind.Issues, data.issues, sessionIssuesPillOptions); + addSections(SessionChatPillKind.Artifacts, data.artifacts, chatArtifactPillOptions); + addSections(SessionChatPillKind.References, data.references, sessionReferencesPillOptions); + addSections(SessionChatPillKind.Customizations, data.customizations, sessionCustomizationsPillOptions); + addSections(SessionChatPillKind.Browsers, data.browsers, sessionBrowsersPillOptions); + addSections(SessionChatPillKind.Subagents, data.subagents, sessionSubagentsPillOptions); + this.sources = sources; + } +} + +/** Shared renderer/controller for status pills above a chat input. */ +export class ChatInputPills extends Disposable { + readonly element: HTMLElement; + readonly onDidChange: Event; + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility = this._onDidChangeVisibility.event; + + private readonly _row: ChatPillsRow; + private readonly _pills: ChatPillsWidget; + private _visible = false; + + constructor( + container: HTMLElement | undefined, + private readonly _options: IChatInputPillsOptions, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @ISessionChatPillVisibilityService private readonly _visibility: ISessionChatPillVisibilityService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._row = this._register(new ChatPillsRow(_options.debugName, { + compact: _options.compact, + targetWindow: _options.targetWindow, + })); + this.element = this._row.element; + if (container) { + container.appendChild(this.element); + this._register(toDisposable(() => this.element.remove())); + } + + const visibleSources = derived(this, reader => { + if (!_options.enabled.read(reader)) { + return []; + } + return _options.sources.read(reader).filter(source => + source.hasData.read(reader) && (!source.kind || this._visibility.isVisible(source.kind, reader))); + }); + const model = { + pills: derived(this, reader => visibleSources.read(reader).map(source => source.pill.read(reader))), + context: _options.context, + }; + this._pills = this._register(instantiationService.createInstance(ChatPillsWidget, model, { + ariaLabel: _options.ariaLabel, + actionRunner: _options.actionRunner, + allowContextMenu: true, + })); + this._pills.element.classList.add('show-file-icons'); + this._row.content.appendChild(this._pills.element); + this._row.observe(this._pills.element); + this._register(this._pills.onDidRemoveFocusedPill(() => this._row.restoreFocus(() => this._pills.getPillElements(), _options.focusFallback))); + this.onDidChange = Event.any(this._row.onDidChangeLayout, this._pills.onDidChangePills); + + const kindsWithData = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { + const kinds = new Set(); + if (!_options.enabled.read(reader)) { + return kinds; + } + for (const source of _options.sources.read(reader)) { + if (source.kind && source.hasData.read(reader)) { + kinds.add(source.kind); + } + } + return kinds; + }); + const showContextMenu = (anchor: HTMLElement | StandardMouseEvent, targetKind?: SessionChatPillKind) => { + const kinds = kindsWithData.get(); + if (kinds.size === 0) { + return; + } + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this._getVisibilityActions(kinds, targetKind), + }); + }; + this._register(addDisposableListener(this._row.content, EventType.CONTEXT_MENU, event => { + event.preventDefault(); + event.stopPropagation(); + const anchor = new StandardMouseEvent(getWindow(this._row.content), event); + showContextMenu(anchor, this._getTargetKind(event.target as HTMLElement | null)); + })); + this._register(this._row.onDidRequestContextMenu(anchor => showContextMenu(anchor, this._getTargetKind(anchor)))); + + derived(this, reader => { + const anyVisible = this._pills.isVisible.read(reader); + const anyHidden = kindsWithData.read(reader).size > 0; + return anyVisible ? 'visible' : anyHidden ? 'empty' : 'hidden'; + }).recomputeInitiallyAndOnChange(this._store, state => { + const activeElement = getWindow(this._row.content).document.activeElement; + const restoreInputFocus = state === 'hidden' && !!activeElement && this._row.content.contains(activeElement); + const visible = state !== 'hidden'; + this.element.classList.toggle('hidden', !visible); + this._row.setEmpty(state === 'empty', localize('chatInputPills.configure', "Configure Session Status Pills")); + if (this._visible !== visible) { + this._visible = visible; + this._onDidChangeVisibility.fire(visible); + } + this._row.scanDomNode(); + if (restoreInputFocus) { + this._row.restoreFocus(() => this._pills.getPillElements(), _options.focusFallback); + } + }); + } + + get visible(): boolean { + return this._visible; + } + + getPillElements(): readonly HTMLElement[] { + return this._pills.getPillElements(); + } + + private _getTargetKind(target: HTMLElement | null): SessionChatPillKind | undefined { + const targetPill = this._pills.getPill(target); + if (!targetPill) { + return undefined; + } + for (const source of this._options.sources.get()) { + if (source.kind && source.pill.get() === targetPill) { + return source.kind; + } + } + return undefined; + } + + private _getVisibilityActions(kindsWithData: ReadonlySet, targetKind?: SessionChatPillKind) { + const menu = getSessionChatPillMenu(kindsWithData, this._visibility.readHiddenKinds(undefined), targetKind, this._options.offeredKinds); + const restoreFocus = () => this._row.restoreFocus(() => this._pills.getPillElements()); + const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ + id: `chatInputPills.toggle.${entry.kind}`, + label: entry.label, + checked: entry.checked, + run: () => { + this._visibility.toggle(entry.kind); + restoreFocus(); + }, + }); + const groups: IAction[][] = []; + if (menu.hide) { + const hide = menu.hide; + groups.push([toAction({ + id: `chatInputPills.hide.${hide.kind}`, + label: hide.label, + run: () => { + this._visibility.hide(hide.kind); + restoreFocus(); + }, + })]); + } + groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); + return Separator.join(...groups); + } +} + +/** Creates a source for a pill whose data presence is represented by its membership. */ +export function createChatInputPillSource(pill: IChatPill, kind?: SessionChatPillKind): IChatInputPillSource { + return { kind, hasData: constObservable(true), pill: constObservable(pill) }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts index 4cf217020a7183..0df12106cc3ed3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts @@ -16,6 +16,9 @@ export interface IChatResponseFileEdit extends IEditSessionEntryDiff { readonly isOutsideWorkspace: boolean; } +/** An authoritative result saying valid edits produced no net file changes. */ +export const AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES: readonly IEditSessionEntryDiff[] = []; + /** * Supplies the per-response (per-request) file-change diffs rendered by the * "Changed N files" summary under a completed chat response. @@ -31,7 +34,9 @@ export interface IChatResponseFileChangesProvider { * Returns an observable of the file-change diffs produced by `requestId` * within `sessionResource`, or `undefined` when this provider cannot * supply changes for that request (in which case the caller falls back to - * the chat editing session). + * the chat editing session). Return + * {@link AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES} when valid edits + * cancel to no net change and consumers must clear previously cached diffs. */ getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts b/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts index 46de3de6f4f5d7..1e887b3c72fed0 100644 --- a/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts +++ b/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts @@ -6,8 +6,28 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditSessionEntryDiff } from '../common/editing/chatEditingService.js'; import { AbstractChatResponseFileChangesService, IChatResponseFileChangesOpenContext } from './chatResponseFileChangesService.js'; +/** Maps a chat-produced file change to the editor resources that represent its actual before/after states. */ +export function toChatFileChangeEditorResource(diff: IEditSessionEntryDiff) { + return { + original: { resource: diff.isCreated ? undefined : diff.originalURI }, + modified: { resource: diff.isDeleted ? undefined : diff.modifiedSnapshotURI ?? diff.modifiedURI }, + goToFileResource: diff.modifiedURI, + }; +} + +/** Opens chat-produced file changes in the standard multi-diff editor. */ +export function openChatFileChanges(editorService: IEditorService, label: string, diffs: readonly IEditSessionEntryDiff[]): void { + const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); + void editorService.openEditor({ + multiDiffSource: source, + label, + resources: diffs.map(toChatFileChangeEditorResource), + }); +} + export class EditorChatResponseFileChangesService extends AbstractChatResponseFileChangesService { constructor( @IEditorService private readonly editorService: IEditorService, @@ -23,14 +43,6 @@ export class EditorChatResponseFileChangesService extends AbstractChatResponseFi if (!diffs?.length) { return; } - const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); - this.editorService.openEditor({ - multiDiffSource: source, - label: localize('chatTurnPills.changes.title', "Turn File Changes"), - resources: diffs.map(diff => ({ - original: { resource: diff.originalURI }, - modified: { resource: diff.isDeleted ? undefined : diff.modifiedURI }, - })), - }); + openChatFileChanges(this.editorService, localize('chatTurnPills.changes.title', "Turn File Changes"), diffs); } } diff --git a/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts b/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts new file mode 100644 index 00000000000000..f3a4108350c057 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../browser/chatDropdownPill.js'; +import { computePullRequestIcon } from '../../../common/chatPullRequest.js'; + +/** Shared presentation of the pull requests pill. */ +export const sessionPullRequestsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionPullRequests', + icon: computePullRequestIcon('open'), + title: localize('sessionPullRequests.title', "Pull Requests"), + summaryLabel: count => count === 1 + ? localize('sessionPullRequests.countSingle', "1 Pull Request") + : localize('sessionPullRequests.count', "{0} Pull Requests", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionPullRequests.showSingle', "Show 1 pull request") + : localize('sessionPullRequests.show', "Show {0} pull requests", count), +}; + +/** Shared presentation of the issues pill. */ +export const sessionIssuesPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionIssues', + icon: Codicon.issues, + title: localize('sessionIssues.title', "Issues"), + summaryLabel: count => count === 1 + ? localize('sessionIssues.countSingle', "1 Issue") + : localize('sessionIssues.count', "{0} Issues", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionIssues.showSingle', "Show 1 issue") + : localize('sessionIssues.show', "Show {0} issues", count), +}; + +/** Shared presentation of the references pill. */ +export const sessionReferencesPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionReferences', + icon: Codicon.bookmark, + title: localize('sessionReferences.title', "References"), + summaryLabel: count => count === 1 + ? localize('sessionReferences.countSingle', "1 Reference") + : localize('sessionReferences.count', "{0} References", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionReferences.showSingle', "Show 1 reference") + : localize('sessionReferences.show', "Show {0} references", count), + singleEntry: ChatPillSingleEntry.Summary, +}; + +/** Shared presentation of the active browsers pill. */ +export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBrowsers', + icon: Codicon.globe, + title: localize('sessionBrowsers.title', "Browsers"), + summaryLabel: count => count === 1 + ? localize('sessionBrowsers.countSingle', "1 Active Browser") + : localize('sessionBrowsers.count', "{0} Active Browsers", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionBrowsers.showSingle', "Show 1 browser") + : localize('sessionBrowsers.show', "Show {0} browsers", count), +}; + +/** Shared presentation of the customizations pill. */ +export const sessionCustomizationsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionCustomizations', + icon: Codicon.bookmark, + title: localize('sessionCustomizations.title', "Customizations"), + summaryLabel: count => count === 1 + ? localize('sessionCustomizations.countSingle', "1 Customization") + : localize('sessionCustomizations.count', "{0} Customizations", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionCustomizations.showSingle', "Show 1 customization") + : localize('sessionCustomizations.show', "Show {0} customizations", count), + singleEntry: ChatPillSingleEntry.Summary, +}; + +/** Shared presentation of the subagents pill. */ +export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionSubagents', + icon: Codicon.agent, + title: localize('sessionSubagents.title', "Background Activities"), + summaryLabel: count => count === 1 + ? localize('sessionSubagents.countSingle', "1 Subagent") + : localize('sessionSubagents.count', "{0} Subagents", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionSubagents.showSingle', "Show 1 subagent") + : localize('sessionSubagents.show', "Show {0} subagents", count), +}; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index 5568750a6c283c..d0f7993a56e7df 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -33,6 +33,7 @@ import { ChatConfiguration } from '../../../common/constants.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatChangesSummaryPart as IChatFileChangesSummaryPart, IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { openChatFileChanges, toChatFileChangeEditorResource } from '../../editorChatResponseFileChangesService.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatTreeItem } from '../../chat.js'; import { ResourcePool } from './chatCollections.js'; @@ -80,7 +81,7 @@ export function renderChangesSummaryFileList( const altKey = (dom.isMouseEvent(item.browserEvent) || dom.isKeyboardEvent(item.browserEvent)) && item.browserEvent.altKey; const openInDiffEditorByDefault = configurationService.getValue(ChatConfiguration.OpenChangedFileInDiffEditor); - const openInDiffEditor = altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault; + const openInDiffEditor = diff.isDeleted || (altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault); if (!openInDiffEditor) { const fileURI = ChatEditingSnapshotTextModelContentProvider.getOriginalFileURI(diff.modifiedURI); @@ -92,9 +93,14 @@ export function renderChangesSummaryFileList( // fall back to the diff editor. } + const editorResource = toChatFileChangeEditorResource(diff); + if (!editorResource.original.resource || !editorResource.modified.resource) { + openChatFileChanges(editorService, localize('chat.fileChanges', "File Changes"), [diff]); + return; + } editorService.openEditor({ - original: { resource: diff.originalURI }, - modified: { resource: diff.modifiedURI }, + original: editorResource.original, + modified: editorResource.modified, options: { preserveFocus: true } }); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts index 22e8af135ffdd2..7becd03a395d2f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts @@ -18,7 +18,7 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatRendererContent, IChatTurnPillsPart } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; -import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; import { EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../chatTurnPills.js'; import { renderChangesSummaryFileList } from './chatChangesSummaryPart.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; @@ -53,7 +53,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent // keep the last non-empty result rather than dropping a rendered summary. this._diffs = derivedObservableWithCache(this, (reader, lastValue) => { const diffs = providedDiffs.read(reader); - return diffs.length > 0 ? diffs : (lastValue ?? diffs); + return diffs.length > 0 || diffs === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES ? diffs : (lastValue ?? diffs); }); const providedStats = this._chatResponseFileChangesService.getChangeStatsForRequest?.( @@ -67,7 +67,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent } const diffs = this._diffs.read(reader); if (diffs.length === 0) { - return lastValue ?? EMPTY_DIFF_STATS; + return diffs === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES ? EMPTY_DIFF_STATS : (lastValue ?? EMPTY_DIFF_STATS); } let insertions = 0, deletions = 0; for (const diff of diffs) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 8fa6099b855ed4..f6b501d7d4e728 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -365,7 +365,7 @@ export class ChatListWidget extends Disposable { private readonly _getCurrentModeInfo: (() => IChatRequestModeInfo | undefined) | undefined; private readonly _useTreeHierarchy: boolean; /** Scrollable space kept below the last item, see {@link IChatListWidgetOptions.paddingBottom}. */ - private readonly _paddingBottom: number; + private _paddingBottom: number; //#endregion @@ -1242,6 +1242,19 @@ export class ChatListWidget extends Disposable { this._renderer.updateOptions(options); } + setPaddingBottom(paddingBottom: number): void { + const value = Math.max(0, paddingBottom); + if (value === this._paddingBottom) { + return; + } + const wasScrolledToBottom = this.isScrolledToBottom; + this._paddingBottom = value; + this._tree.updateOptions({ paddingBottom: value }); + if (wasScrolledToBottom) { + this.scrollToEnd(); + } + } + /** * Update the list/tree color overrides, including the sticky-scroll surface. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 378b59a8e9407c..ade34c6c292cce 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -377,6 +377,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private listContainer!: HTMLElement; private container!: HTMLElement; + private _persistentContentHeight: number; private transcriptProgress: { readonly container: HTMLElement; readonly content: HTMLElement } | undefined; private readonly transcriptProgressPart = this._register(new MutableDisposable()); private transcriptProgressActive = false; @@ -608,6 +609,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); + this._persistentContentHeight = viewOptions.persistentContentHeight ?? 0; this.readOnlyBanner = viewOptions.isSessionsWindow ? undefined @@ -1086,12 +1088,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } })); this.updateSessionStateIndicator(); - if (this.viewOptions.persistentContentHeight) { - // The class floats the persistent content; the variable tells the - // surfaces the list now extends behind how far to keep clear. - this.container.classList.add(chatFloatingPersistentContentClass); - this.container.style.setProperty(chatPersistentContentHeightVariable, `${this.viewOptions.persistentContentHeight}px`); - } + this._applyPersistentContentHeight(); this.editorOverflowWidgetsDomNode = this.viewOptions.editorOverflowWidgetsDomNode; if (!this.editorOverflowWidgetsDomNode) { const editorOverflowWidgetsDomNode = this.layoutService.getContainer(dom.getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); @@ -2120,7 +2117,7 @@ export class ChatWidget extends Disposable implements IChatWidget { getSelectedModelRequestOptions: () => this.getSelectedModelRequestOptions(), getCurrentModeInfo: () => this.input.currentModeInfo, getEditingValue: () => this.input.inputEditor.getValue(), - paddingBottom: this.viewOptions.persistentContentHeight, + paddingBottom: this._persistentContentHeight, } )); @@ -2698,6 +2695,28 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + setPersistentContentHeight(height: number | undefined): void { + const persistentContentHeight = Math.max(0, height ?? 0); + if (persistentContentHeight === this._persistentContentHeight) { + return; + } + this._persistentContentHeight = persistentContentHeight; + this._applyPersistentContentHeight(); + } + + private _applyPersistentContentHeight(): void { + if (!this.container) { + return; + } + const floatsPersistentContent = this._persistentContentHeight > 0; + this.container.classList.toggle(chatFloatingPersistentContentClass, floatsPersistentContent); + if (floatsPersistentContent) { + this.container.style.setProperty(chatPersistentContentHeightVariable, `${this._persistentContentHeight}px`); + } else { + this.container.style.removeProperty(chatPersistentContentHeightVariable); + } + this.listWidget?.setPaddingBottom(this._persistentContentHeight); + } setModel(model: IChatModel | undefined): void { if (!this.container || !this.inputPart) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index d83dfdee8b4411..96bfa813316dcf 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -33,6 +33,7 @@ import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind, IResolvedNewChatSessionType, SessionTypeSelectionReason } from '../../../common/constants.js'; import { clearChatEditor } from '../../actions/chatClear.js'; +import { AgentHostSessionInputPills } from '../../agentSessions/agentHost/agentHostSessionInputPills.js'; import { ChatEditorInput } from './chatEditorInput.js'; import { ChatWidget } from '../../widget/chatWidget.js'; import { IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; @@ -151,6 +152,7 @@ export class ChatEditor extends AbstractEditorWithViewState this._widget.setVisible(this.isBodyVisible() && !this.welcomeController?.isShowingWelcome.read(reader)); this._register(this.onDidChangeBodyVisibility(() => updateWidgetVisibility())); diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 92e773e0d65050..f857ed46437ff1 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -320,6 +320,9 @@ export interface IEditSessionEntryDiff extends IEditSessionDiffStats { */ modifiedSnapshotURI?: URI; + /** Whether the modified resource was created by this edit. */ + isCreated?: boolean; + /** Whether the modified resource was deleted by this edit. */ isDeleted?: boolean; diff --git a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/workbench/contrib/chat/common/sessionChatPills.ts similarity index 81% rename from src/vs/sessions/contrib/chat/common/sessionChatPills.ts rename to src/vs/workbench/contrib/chat/common/sessionChatPills.ts index bc5c0c46e7fda4..ee1b22d775dc89 100644 --- a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts +++ b/src/vs/workbench/contrib/chat/common/sessionChatPills.ts @@ -6,10 +6,11 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IReader } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { observableMemento, ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -/** The kinds of pill shown above the chat input, each independently hideable. */ +/** The kinds of pill shown above an agent chat input, each independently hideable. */ export const enum SessionChatPillKind { Changes = 'changes', Artifacts = 'artifacts', @@ -24,11 +25,11 @@ export const enum SessionChatPillKind { /** All pill kinds, in the order they are offered in the visibility menu. */ export const SESSION_CHAT_PILL_KINDS: readonly SessionChatPillKind[] = [ SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, SessionChatPillKind.Artifacts, SessionChatPillKind.References, SessionChatPillKind.Customizations, - SessionChatPillKind.PullRequests, - SessionChatPillKind.Issues, SessionChatPillKind.Browsers, SessionChatPillKind.Subagents, ]; @@ -64,7 +65,7 @@ export interface ISessionChatPillMenuEntry { /** * The pill visibility context menu: an optional "Hide X" for the pill that was - * right-clicked, then the kinds the session has data for, then the rest. The + * right-clicked, then the kinds the surface has data for, then the rest. The * caller renders a separator between the groups it shows. */ export interface ISessionChatPillMenu { @@ -74,21 +75,21 @@ export interface ISessionChatPillMenu { } /** - * Builds the visibility menu. Every hideable kind is listed and toggleable, - * checked while it is not hidden, grouped by whether the session has data for it. - * - * @param targetKind The pill that was right-clicked, which gains a "Hide X" - * entry. Omitted when the click did not land on a pill. + * Builds the visibility menu. Every offered, hideable kind is listed and + * toggleable, checked while it is not hidden, and grouped by whether the + * current session has data for it. */ export function getSessionChatPillMenu( kindsWithData: ReadonlySet, hiddenKinds: ReadonlySet, targetKind?: SessionChatPillKind, + offeredKinds: readonly SessionChatPillKind[] = SESSION_CHAT_PILL_KINDS, ): ISessionChatPillMenu { + const offered = new Set(offeredKinds); const withData: ISessionChatPillMenuEntry[] = []; const withoutData: ISessionChatPillMenuEntry[] = []; for (const kind of SESSION_CHAT_PILL_KINDS) { - if (!isSessionChatPillHideable(kind)) { + if (!offered.has(kind) || !isSessionChatPillHideable(kind)) { continue; } (kindsWithData.has(kind) ? withData : withoutData).push({ @@ -98,7 +99,7 @@ export function getSessionChatPillMenu( }); } - const hide = targetKind !== undefined && isSessionChatPillHideable(targetKind) + const hide = targetKind !== undefined && offeredKinds.includes(targetKind) && isSessionChatPillHideable(targetKind) ? { kind: targetKind, label: localize('sessionChatPills.hide', "Hide {0}", getSessionChatPillLabel(targetKind)) } : undefined; @@ -124,8 +125,20 @@ const hiddenSessionChatPills = observableMemento({ }, }); +export const ISessionChatPillVisibilityService = createDecorator('sessionChatPillVisibilityService'); + +export interface ISessionChatPillVisibilityService { + readonly _serviceBrand: undefined; + readHiddenKinds(reader: IReader | undefined): ReadonlySet; + isVisible(kind: SessionChatPillKind, reader: IReader | undefined): boolean; + hide(kind: SessionChatPillKind): void; + toggle(kind: SessionChatPillKind): void; +} + /** The user's per-kind pill visibility choices, persisted across windows. */ -export class SessionChatPillVisibility extends Disposable { +export class SessionChatPillVisibility extends Disposable implements ISessionChatPillVisibilityService { + + declare readonly _serviceBrand: undefined; private readonly _hiddenKinds: ObservableMemento; diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index f168203a642ce3..9fff3da414c380 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -117,6 +117,26 @@ suite('Chat Accessibility Help', () => { }); }); + test('documents session status pill keyboard interaction', () => { + const keybindingService = { + lookupKeybindings: () => [], + } as unknown as IKeybindingService; + + assert.deepStrictEqual({ + panelChat: getAccessibilityHelpText('panelChat', keybindingService, true).includes('left and right arrow keys to move between pills'), + agentView: getAccessibilityHelpText('agentView', keybindingService, true).includes(''), + agentQuickChat: getAccessibilityHelpText('agentView', keybindingService, true, false, false, false).includes('session status pills'), + quickChat: getAccessibilityHelpText('quickChat', keybindingService, true).includes('session status pills'), + inlineChat: getAccessibilityHelpText('inlineChat', keybindingService, true).includes('session status pills'), + }, { + panelChat: true, + agentView: true, + agentQuickChat: false, + quickChat: false, + inlineChat: false, + }); + }); + test('documents transcript Find everywhere it is enabled, but not in quick chat', () => { const keybindingService = { lookupKeybindings: () => [], diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts index f02a26a8a7a14c..f92139f5fccf72 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -31,7 +31,7 @@ import { } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { AgentHostResponseFileChangesProvider } from '../../../browser/agentSessions/agentHost/agentHostResponseFileChanges.js'; -import { IChatResponseFileEdit } from '../../../browser/chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileEdit } from '../../../browser/chatResponseFileChangesService.js'; class FakeAgentConnection extends mock() { override readonly clientId = 'test-client'; @@ -148,6 +148,39 @@ suite('AgentHostResponseFileChangesProvider', () => { ]); }); + test('wraps local non-file snapshots through the Agent Host file system', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, 'local', () => backendSession, undefined, new NullLogService())); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { + status: ChangesetStatus.Ready, + files: [{ + id: '1', + edit: { + before: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-before' } }, + after: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-after' } }, + diff: { added: 1, removed: 1 }, + }, + }], + } satisfies ChangesetState); + + const diff = observe(provider, ds).latest()[0]; + + assert.deepStrictEqual({ + originalScheme: diff.originalURI.scheme, + originalAuthority: diff.originalURI.authority, + originalSource: fromAgentHostUri(diff.originalURI).toString(), + modifiedSource: diff.modifiedSnapshotURI && fromAgentHostUri(diff.modifiedSnapshotURI).toString(), + }, { + originalScheme: 'vscode-agent-host', + originalAuthority: 'local', + originalSource: 'git-blob://a-before/', + modifiedSource: 'git-blob://a-after/', + }); + }); + test('keeps the changeset subscription when session state updates', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); @@ -218,6 +251,146 @@ suite('AgentHostResponseFileChangesProvider', () => { ); }); + test('includes deleted response edits when a turn checkpoint is unavailable', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); + const provider = ds.add(createProvider(conn, () => backendSession, () => defaultChatUri)); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Computing, files: [] } satisfies ChangesetState); + conn.setState(defaultChatUri.toString(), { + turns: [{ + id: 't1', + responseParts: [{ + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: URI.file('/repo/deleted.ts').toString(), content: { uri: 'git-blob://deleted-before' } }, + diff: { added: 0, removed: 6 }, + }], + }, + }], + }], + } as unknown as ChatState); + + const { latest } = observe(provider, ds); + assert.deepStrictEqual(latest().map(diff => ({ + file: fromAgentHostUri(diff.modifiedURI).path, + before: fromAgentHostUri(diff.originalURI).authority, + after: diff.modifiedSnapshotURI, + isCreated: diff.isCreated, + isDeleted: diff.isDeleted, + })), [{ + file: '/repo/deleted.ts', + before: 'deleted-before', + after: undefined, + isCreated: false, + isDeleted: true, + }]); + }); + + test('aggregates response edits by first and final file state', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); + const provider = ds.add(createProvider(conn, () => backendSession, () => defaultChatUri)); + const replaceResource = URI.file('/repo/replaced.ts').toString(); + const transientResource = URI.file('/repo/transient.ts').toString(); + const responseParts = [ + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: replaceResource, content: { uri: 'git-blob://replace-before' } }, + diff: { added: 0, removed: 4 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + after: { uri: replaceResource, content: { uri: 'git-blob://replace-after' } }, + diff: { added: 5, removed: 0 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + after: { uri: transientResource, content: { uri: 'git-blob://transient-after' } }, + diff: { added: 3, removed: 0 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: transientResource, content: { uri: 'git-blob://transient-before-delete' } }, + diff: { added: 0, removed: 3 }, + }], + }, + }, + ]; + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Computing, files: [] } satisfies ChangesetState); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts: responseParts.slice(0, 3) }], + } as unknown as ChatState); + + const { latest } = observe(provider, ds); + const beforeCancellation = latest().map(diff => fromAgentHostUri(diff.modifiedURI).path); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts }], + } as unknown as ChatState); + const afterCancellation = latest().map(diff => ({ + file: fromAgentHostUri(diff.modifiedURI).path, + before: fromAgentHostUri(diff.originalURI).authority, + after: diff.modifiedSnapshotURI && fromAgentHostUri(diff.modifiedSnapshotURI).authority, + added: diff.added, + removed: diff.removed, + isCreated: diff.isCreated, + isDeleted: diff.isDeleted, + })); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts: responseParts.slice(2) }], + } as unknown as ChatState); + + assert.deepStrictEqual({ + beforeCancellation, + afterCancellation, + isAuthoritativeEmpty: latest() === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, + afterAllCancellation: latest(), + }, { + beforeCancellation: ['/repo/replaced.ts', '/repo/transient.ts'], + afterCancellation: [{ + file: '/repo/replaced.ts', + before: 'replace-before', + after: 'replace-after', + added: 5, + removed: 4, + isCreated: false, + isDeleted: false, + }], + isAuthoritativeEmpty: true, + afterAllCancellation: [], + }); + }); + test('preserves an authoritative empty turn changeset', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); @@ -244,7 +417,13 @@ suite('AgentHostResponseFileChangesProvider', () => { } as unknown as ChatState); const { latest } = observe(provider, ds); - assert.deepStrictEqual(latest(), []); + assert.deepStrictEqual({ + diffs: latest(), + isAuthoritativeEmpty: latest() === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, + }, { + diffs: [], + isAuthoritativeEmpty: true, + }); }); test('the recorded migrated turn falls back to the branch changeset when its turn changeset is empty', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts new file mode 100644 index 00000000000000..38846533a203fc --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ISessionArtifact, SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { buildDefaultChatUri, buildSubagentChatUri, Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, ComponentToState, SessionState, StateComponents, withSessionGitHubState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { AgentHostSessionInputPills, getAgentHostSessionBrowserOwnerIds, getAgentHostSessionPillMetadata, resolveAgentHostSessionChangeset } from '../../../browser/agentSessions/agentHost/agentHostSessionInputPills.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { chatPersistentContentVisibleClass, ChatWidget } from '../../../browser/widget/chatWidget.js'; +import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; +import { ChatViewModel } from '../../../common/model/chatViewModel.js'; + +class StaticAgentConnection extends mock() { + readonly requested: Array<{ kind: StateComponents; resource: URI }> = []; + private readonly emitters = new Map>(); + + constructor(private readonly values: ReadonlyMap) { + super(); + } + + override getSubscription(kind: T, resource: URI): IReference> { + this.requested.push({ kind, resource }); + let emitter = this.emitters.get(kind); + if (!emitter) { + emitter = new Emitter(); + this.emitters.set(kind, emitter); + } + const values = this.values; + return { + object: { + get value() { return values.get(kind) as ComponentToState[T]; }, + get verifiedValue() { return values.get(kind) as ComponentToState[T]; }, + onDidChange: emitter.event as Event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + } + + setState(kind: StateComponents, value: SessionState | ChangesetState): void { + (this.values as Map).set(kind, value); + this.emitters.get(kind)?.fire(value); + } +} + +suite('AgentHostSessionInputPills', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('partitions GitHub links, artifacts, and references without duplication', () => { + const entries: readonly ISessionArtifact[] = [ + { id: 'created-pr', type: SessionArtifactType.PullRequest, label: 'Created PR', link: 'https://github.com/microsoft/vscode/pull/2', isGitHub: true, isArtifact: true }, + { id: 'duplicate-pr', type: SessionArtifactType.PullRequest, label: 'Existing PR', link: 'https://github.com/microsoft/vscode/pull/1/', isGitHub: true, isArtifact: false }, + { id: 'created-issue', type: SessionArtifactType.Issue, label: 'Created Issue', link: 'https://github.com/microsoft/vscode/issues/3', isGitHub: true, isArtifact: true }, + { id: 'issue-reference', type: SessionArtifactType.Issue, label: 'Related Issue', link: 'https://github.com/microsoft/vscode/issues/4', isGitHub: true, isArtifact: false }, + { id: 'website', type: SessionArtifactType.Website, label: 'Preview', link: 'https://example.com', isArtifact: true }, + { id: 'resource', type: SessionArtifactType.Resource, label: 'Docs', uri: 'https://example.com/docs', isArtifact: false }, + ]; + const meta = withSessionGitHubState( + withSessionArtifacts(undefined, entries), + { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + }, + ); + + const metadata = getAgentHostSessionPillMetadata(meta); + + assert.deepStrictEqual({ + pullRequestUrls: metadata.pullRequestUrls, + issueUrls: metadata.issueUrls, + artifactIds: metadata.artifacts.map(artifact => artifact.id), + referenceIds: metadata.references.map(reference => reference.id), + }, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + ], + issueUrls: ['https://github.com/microsoft/vscode/issues/3'], + artifactIds: ['website'], + referenceIds: ['issue-reference', 'resource'], + }); + }); + + test('resolves the configured session changeset and ignores templated entries', () => { + const backendSession = URI.parse('ahp-session:/session'); + const changesets: readonly Changeset[] = [ + { label: 'Last Turn', uriTemplate: 'changeset/turn/{turnId}', changeKind: ChangesetKind.Turn }, + { label: 'Session Changes', uriTemplate: 'changeset/session', changeKind: ChangesetKind.Session }, + { label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }, + ]; + + assert.deepStrictEqual({ + preferred: resolveAgentHostSessionChangeset(backendSession, changesets, ChangesetKind.Session), + fallback: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 2), ChangesetKind.Branch), + turnOnly: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 1), ChangesetKind.Session), + }, { + preferred: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + fallback: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + turnOnly: undefined, + }); + }); + + test('includes browsers owned by direct tool-origin child chats', () => { + const sessionResource = URI.parse('vscode-chat-session://agent-host/session'); + const backendSession = URI.parse('ahp-session://host/session'); + const parentChat = buildDefaultChatUri(backendSession); + const childChat = buildSubagentChatUri(backendSession, 'tool-1'); + const unrelatedChildChat = buildSubagentChatUri(backendSession, 'tool-2'); + const childChatId = 'subagent/tool-1'; + const stateWithoutChild = { + defaultChat: parentChat, + chats: [], + } as unknown as SessionState; + const stateWithChild = { + defaultChat: parentChat, + chats: [{ + resource: childChat, + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tool-1' }, + }, { + resource: unrelatedChildChat, + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(URI.parse('ahp-session://host/other')), toolCallId: 'tool-2' }, + }], + } as unknown as SessionState; + const explicitQuery = new URLSearchParams(); + explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, childChat); + const canonicalChildResource = sessionResource.with({ fragment: childChatId, query: null }); + const explicitChildResource = sessionResource.with({ fragment: childChatId, query: explicitQuery.toString() }); + + const before = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithoutChild); + const after = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithChild); + + assert.deepStrictEqual({ + before: [...before], + after: [...after], + hasCanonicalChild: after.has(canonicalChildResource.toString()), + hasExplicitChild: after.has(explicitChildResource.toString()), + hasUnrelatedChild: after.has(sessionResource.with({ fragment: 'subagent/tool-2', query: null }).toString()), + }, { + before: [sessionResource.toString()], + after: [ + sessionResource.toString(), + canonicalChildResource.toString(), + explicitChildResource.toString(), + ], + hasCanonicalChild: true, + hasExplicitChild: true, + hasUnrelatedChild: false, + }); + }); + + test('does not render pills for a Local chat input', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('vscode-chat-session://local/session'); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => undefined, + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + + assert.deepStrictEqual({ + hidden: row?.classList.contains('hidden'), + pillCount: row?.querySelectorAll('.chat-pill-item').length, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, { + hidden: true, + pillCount: 0, + persistentContentVisible: false, + persistentContentHeight: undefined, + }); + }); + + test('marks floating persistent content visible when Agent Host pills have data', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as unknown as ChangesetState], + ])); + const otherConnection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as unknown as ChangesetState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const resolutionChanged = new Emitter(); + let currentConnection = connection; + let connectionAuthority = 'local'; + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: resolutionChanged.event, + connections: [], + resolveSessionResource: () => ({ + connection: currentConnection, + connectionAuthority, + backendSession, + defaultChangesetKind: ChangesetKind.Branch, + }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + const button = row?.querySelector('.chat-pill-button'); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + const recomputing = { + hidden: row?.classList.contains('hidden'), + buttonPreserved: row?.querySelector('.chat-pill-button') === button, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [], + } as ChangesetState); + const readyEmpty = { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as ChangesetState); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + currentConnection = otherConnection; + connectionAuthority = 'remote'; + resolutionChanged.fire(); + + assert.deepStrictEqual({ + recomputing, + readyEmpty, + otherConnection: { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, + subscriptions: [...new Map(connection.requested.map(request => { + const value = { kind: request.kind, resource: request.resource.toString() }; + return [`${value.kind}:${value.resource}`, value]; + })).values()], + }, { + recomputing: { + hidden: false, + buttonPreserved: true, + persistentContentVisible: true, + persistentContentHeight: 28, + }, + readyEmpty: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + otherConnection: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + subscriptions: [{ + kind: StateComponents.Session, + resource: 'copilot:/session', + }, { + kind: StateComponents.Changeset, + resource: 'copilot:/session/changeset/branch', + }], + }); + }); + + test('matches the Agents Window pull request summary presentation', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + 'https://github.com/microsoft/vscode/pull/3', + ], + // Only pull request #1 is merged; the other entries must retain their open state. + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [{ authority: 'local', address: undefined, name: 'Local', isAmbient: true, connection }], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const button = persistentContent.querySelector('.chat-dropdown-pill-button'); + const icon = button?.querySelector('.chat-pill-icon'); + const multiple = { + button, + label: button?.querySelector('.chat-pill-label')?.textContent, + iconClass: icon?.classList.contains('codicon-git-pull-request'), + iconColor: icon?.style.color, + hasChevron: button?.querySelector('.chat-pill-chevron') !== null, + }; + connection.setState(StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState); + const singleButton = persistentContent.querySelector('.chat-dropdown-pill-button'); + const singleIcon = singleButton?.querySelector('.chat-pill-icon'); + + assert.deepStrictEqual({ + multiple, + single: { + buttonPreserved: singleButton === multiple.button, + label: singleButton?.querySelector('.chat-pill-label')?.textContent, + iconClass: singleIcon?.classList.contains('codicon-git-pull-request-done'), + iconColor: singleIcon?.style.color, + hasChevron: singleButton?.querySelector('.chat-pill-chevron') !== null, + }, + }, { + multiple: { + button, + label: '3 Pull Requests', + iconClass: true, + iconColor: 'var(--vscode-charts-green)', + hasChevron: true, + }, + single: { + buttonPreserved: true, + label: '#1', + iconClass: true, + iconColor: 'var(--vscode-charts-purple)', + hasChevron: false, + }, + }); + }); + + test('keeps a matching website artifact visible while Browsers is hidden', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const website = URI.parse('https://example.com/preview'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionArtifacts(undefined, [{ + id: 'preview', + type: SessionArtifactType.Website, + label: 'Preview', + link: website.toString(), + isArtifact: true, + }]), + } as unknown as SessionState], + ])); + const browserModel = upcastPartial({ + owner: { type: 'agent', sessionId: sessionResource.toString() }, + }); + const browser = new class extends mock() { + override get id(): string { return 'preview-browser'; } + override get model(): IBrowserViewModel { return browserModel; } + override get url(): string { return website.toString(); } + override get title(): string { return 'Preview'; } + override readonly onDidChangeLabel = Event.None; + }(); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map([[browser.id, browser]]), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set([SessionChatPillKind.Browsers]), + isVisible: kind => kind !== SessionChatPillKind.Browsers, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + + assert.deepStrictEqual({ + pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + empty: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('empty'), + }, { + pills: ['1 Artifact'], + empty: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts new file mode 100644 index 00000000000000..67f332c77e138a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatInputPills, createChatInputPillSource, StandardChatInputPillSources, type IStandardChatInputPillsData } from '../../browser/chatInputPills.js'; +import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../common/sessionChatPills.js'; + +suite('StandardChatInputPillSources', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses one canonical composition for different offered kind sets', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sections = constObservable([]); + const data: IStandardChatInputPillsData = { + changes: { + stats: constObservable({ files: 1, insertions: 2, deletions: 1 }), + label: constObservable('Changes'), + open: () => { }, + }, + pullRequests: { sections }, + issues: { sections }, + artifacts: { sections }, + references: { sections }, + customizations: { sections }, + browsers: { sections }, + subagents: { sections }, + }; + const full = store.add(instantiationService.createInstance(StandardChatInputPillSources, data, SESSION_CHAT_PILL_KINDS)); + const editorKinds = [ + SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Artifacts, + SessionChatPillKind.References, + SessionChatPillKind.Browsers, + ]; + const editor = store.add(instantiationService.createInstance(StandardChatInputPillSources, data, editorKinds)); + + assert.deepStrictEqual({ + full: full.sources.map(source => source.kind), + editor: editor.sources.map(source => source.kind), + }, { + full: SESSION_CHAT_PILL_KINDS, + editor: editorKinds, + }); + }); + + test('keeps the row available for restoring a hidden pill', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const hidden = observableValue('hiddenPills', false); + instantiationService.stub(ISessionChatPillVisibilityService, { + _serviceBrand: undefined, + readHiddenKinds: reader => hidden.read(reader) ? new Set([SessionChatPillKind.Browsers]) : new Set(), + isVisible: (kind, reader) => kind !== SessionChatPillKind.Browsers || !hidden.read(reader), + hide: () => hidden.set(true, undefined), + toggle: () => hidden.set(!hidden.get(), undefined), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + const hasData = observableValue('browserPill.hasData', true); + const source = { + ...createChatInputPillSource({ action: store.add(new Action('browser', 'Browser')) }, SessionChatPillKind.Browsers), + hasData, + }; + const overlayFocus = document.createElement('button'); + container.appendChild(overlayFocus); + let focusFallbackCount = 0; + const inputPills = store.add(instantiationService.createInstance(ChatInputPills, container, { + debugName: 'ChatInputPills.test', + compact: false, + enabled: constObservable(true), + sources: constObservable([source]), + offeredKinds: [SessionChatPillKind.Browsers], + focusFallback: () => { + focusFallbackCount++; + overlayFocus.focus(); + }, + })); + const before = { + hidden: inputPills.element.classList.contains('hidden'), + empty: inputPills.element.classList.contains('empty'), + pillCount: inputPills.getPillElements().length, + }; + + overlayFocus.focus(); + inputPills.getPillElements()[0].setAttribute('aria-expanded', 'true'); + hidden.set(true, undefined); + await timeout(0); + const afterHidden = { + hidden: inputPills.element.classList.contains('hidden'), + empty: inputPills.element.classList.contains('empty'), + pillCount: inputPills.getPillElements().length, + emptyRowFocused: document.activeElement === inputPills.element.querySelector('.chat-pills-row-content'), + }; + hasData.set(false, undefined); + await timeout(0); + + assert.deepStrictEqual({ + before, + after: afterHidden, + afterDataRemoved: { + hidden: inputPills.element.classList.contains('hidden'), + focusFallbackCount, + fallbackFocused: document.activeElement === overlayFocus, + }, + }, { + before: { + hidden: false, + empty: false, + pillCount: 1, + }, + after: { + hidden: false, + empty: true, + pillCount: 0, + emptyRowFocused: true, + }, + afterDataRemoved: { + hidden: true, + focusFallbackCount: 1, + fallbackFocused: true, + }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts index b2b79ec99caec8..4f3323e94e012c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts @@ -29,6 +29,7 @@ suite('EditorChatResponseFileChangesService', () => { ? constObservable([{ originalURI: URI.file('/before.ts'), modifiedURI: URI.file('/after.ts'), + modifiedSnapshotURI: URI.file('/after-snapshot.ts'), added: 2, removed: 1, quitEarly: false, @@ -45,6 +46,17 @@ suite('EditorChatResponseFileChangesService', () => { identical: false, isFinal: true, isBusy: false, + }, { + originalURI: URI.file('/created.ts'), + modifiedURI: URI.file('/created.ts'), + modifiedSnapshotURI: URI.file('/created-snapshot.ts'), + isCreated: true, + added: 4, + removed: 0, + quitEarly: false, + identical: false, + isFinal: true, + isBusy: false, }]) : undefined, })); @@ -58,15 +70,22 @@ suite('EditorChatResponseFileChangesService', () => { resources: opened.resources?.map(resource => ({ original: resource.original.resource?.toString(), modified: resource.modified.resource?.toString(), + goToFile: resource.goToFileResource?.toString(), })), }, { label: 'Turn File Changes', resources: [{ original: 'file:///before.ts', - modified: 'file:///after.ts', + modified: 'file:///after-snapshot.ts', + goToFile: 'file:///after.ts', }, { original: 'file:///deleted-before.ts', modified: undefined, + goToFile: 'file:///deleted.ts', + }, { + original: undefined, + modified: 'file:///created-snapshot.ts', + goToFile: 'file:///created.ts', }], }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts index 709659b26ddc0e..62d1fa971c9e56 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts @@ -8,8 +8,10 @@ import { toAction } from '../../../../../../../base/common/actions.js'; import { Disposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { isResourceDiffEditorInput, isResourceMultiDiffEditorInput } from '../../../../../../common/editor.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; import { IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; @@ -154,4 +156,74 @@ suite('ChatCheckpointFileChangesSummaryContentPart', () => { ], }); }); + + test('opens row diffs using snapshots and missing create or delete sides', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const opened: unknown[] = []; + const editorService = new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + opened.push(args[0]); + return undefined; + } + }(); + const configurationService = new class extends mock() { + override getValue(): T { + return true as T; + } + }(); + const container = document.createElement('div'); + const diffs = observableValue('testFileChanges', [{ + ...emptySessionEntryDiff(URI.file('/edited-before.ts'), URI.file('/edited.ts')), + modifiedSnapshotURI: URI.file('/edited-snapshot.ts'), + }, { + ...emptySessionEntryDiff(URI.file('/created.ts'), URI.file('/created.ts')), + modifiedSnapshotURI: URI.file('/created-snapshot.ts'), + isCreated: true, + }, { + ...emptySessionEntryDiff(URI.file('/deleted-before.ts'), URI.file('/deleted.ts')), + isDeleted: true, + }]); + store.add(renderChangesSummaryFileList(container, diffs, instantiationService, editorService, configurationService)); + + for (const row of container.querySelectorAll('.monaco-list-row')) { + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } + + assert.deepStrictEqual(opened.map(input => { + if (isResourceDiffEditorInput(input)) { + return { + kind: 'diff', + original: input.original.resource?.toString(), + modified: input.modified.resource?.toString(), + }; + } + assert.ok(isResourceMultiDiffEditorInput(input)); + return { + kind: 'multiDiff', + resources: input.resources?.map(resource => ({ + original: resource.original.resource?.toString(), + modified: resource.modified.resource?.toString(), + goToFile: resource.goToFileResource?.toString(), + })), + }; + }), [{ + kind: 'diff', + original: 'file:///edited-before.ts', + modified: 'file:///edited-snapshot.ts', + }, { + kind: 'multiDiff', + resources: [{ + original: undefined, + modified: 'file:///created-snapshot.ts', + goToFile: 'file:///created.ts', + }], + }, { + kind: 'multiDiff', + resources: [{ + original: 'file:///deleted-before.ts', + modified: undefined, + goToFile: 'file:///deleted.ts', + }], + }]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts index f29b54cafc6967..18ce6caf868a31 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts @@ -9,7 +9,7 @@ import { observableValue } from '../../../../../../../base/common/observable.js' import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; -import { IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatTurnPillsContentPart } from '../../../../browser/widget/chatContentParts/chatTurnPillsPart.js'; @@ -19,7 +19,7 @@ import { IChatTurnPillsPart } from '../../../../common/model/chatViewModel.js'; suite('ChatTurnPillsContentPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('keeps the turn changes summary once it has been shown', () => { + test('keeps transient empty changes and clears authoritative empty changes', () => { const instantiationService = workbenchInstantiationService(undefined, store); const diffs = observableValue('turnChanges', []); instantiationService.stub(IChatResponseFileChangesService, { @@ -60,11 +60,14 @@ suite('ChatTurnPillsContentPart', () => { // The changeset recompute at turn end briefly reports no files. diffs.set([], undefined); states.push(readState()); + diffs.set(AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, undefined); + states.push(readState()); assert.deepStrictEqual(states, [ { display: 'none', files: '0 files changed', additions: '+0', deletions: '-0' }, { display: '', files: '2 files changed', additions: '+8', deletions: '-3' }, { display: '', files: '2 files changed', additions: '+8', deletions: '-3' }, + { display: 'none', files: '0 files changed', additions: '+0', deletions: '-0' }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 9dfe421336f7dd..e0810c36d33ed0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -291,8 +291,8 @@ suite('ChatListWidget', () => { // The bottom padding counts towards the scroll height, so `scrollToEnd` has to // scroll through it or the list never reports being at the bottom - which both // streaming auto-scroll and the scroll-down button depend on. - test('scrolls through the bottom padding to reach the end', async () => { - const { disposables, model, widget } = createWidget({ paddingBottom: 30 }); + test('updates bottom padding while keeping the list at the end', async () => { + const { disposables, model, widget } = createWidget(); for (let i = 0; i < 10; i++) { const text = `question ${i}`; const request = model.addRequest({ @@ -307,13 +307,19 @@ suite('ChatListWidget', () => { await waitForStableLayout(widget); widget.scrollToEnd(); await waitForStableLayout(widget); + const scrollHeightWithoutPadding = widget.scrollHeight; + + widget.setPaddingBottom(30); + await waitForStableLayout(widget); assert.deepStrictEqual({ // Guards the test from passing vacuously on a list that cannot scroll. overflows: widget.scrollHeight > widget.renderHeight, + paddingAdded: widget.scrollHeight - scrollHeightWithoutPadding, atBottom: widget.isScrolledToBottom, }, { overflows: true, + paddingAdded: 30, atBottom: true, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts index f878922c52ee83..95598831ee848c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -107,6 +107,7 @@ suite('ChatTurnPills', () => { label: 'plan.md', resource: URI.file('/repo/plan.md'), ariaLabel: 'Open plan.md', + ariaDescription: 'file:///repo/plan.md', tooltip: 'file:///repo/plan.md', open: () => { }, }; @@ -115,15 +116,25 @@ suite('ChatTurnPills', () => { disposables.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, action, {}, constObservable([{ title: 'Files', entries: [entry] }]), chatArtifactPillOptions)), ]; - const ariaLabels = items.map(item => { + const accessibility = items.map(item => { const container = document.createElement('div'); mainWindow.document.body.appendChild(container); disposables.add(toDisposable(() => container.remove())); item.render(container); - return container.querySelector('.monaco-button')?.getAttribute('aria-label'); + const button = container.querySelector('.monaco-button'); + return { + label: button?.getAttribute('aria-label'), + description: button?.getAttribute('aria-description'), + }; }); - assert.deepStrictEqual(ariaLabels, ['Open plan.md', 'Open plan.md']); + assert.deepStrictEqual(accessibility, [{ + label: 'Open plan.md', + description: 'file:///repo/plan.md', + }, { + label: 'Open plan.md', + description: 'file:///repo/plan.md', + }]); }); test('focusing a pill restores its tab stop, so the row stays reachable by Tab', () => { @@ -214,6 +225,7 @@ suite('ChatTurnPills', () => { override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[]): void { shownItems = items.map(item => ({ kind: item.kind, label: item.label, ariaDescription: item.ariaDescription, hover: typeof item.hover?.content === 'string' ? item.hover.content : undefined })); } + override hide(): void { } }); const opened: string[] = []; const widget = disposables.add(instantiationService.createInstance(ChatTurnPillsWidget, { diff --git a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts similarity index 87% rename from src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts rename to src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts index 573248c7c5e84b..215f96d83722ff 100644 --- a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility } from '../../common/sessionChatPills.js'; suite('SessionChatPills', () => { @@ -23,10 +23,10 @@ suite('SessionChatPills', () => { { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true }, ], withoutData: [ + { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, { kind: SessionChatPillKind.References, label: 'References', checked: true }, { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true }, - { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true }, ], }); @@ -46,6 +46,25 @@ suite('SessionChatPills', () => { }); }); + test('limits the menu to the pill kinds offered by the surface', () => { + assert.deepStrictEqual( + getSessionChatPillMenu( + new Set([SessionChatPillKind.PullRequests, SessionChatPillKind.Browsers]), + new Set(), + SessionChatPillKind.Browsers, + [SessionChatPillKind.Changes, SessionChatPillKind.Artifacts, SessionChatPillKind.PullRequests], + ), + { + withData: [ + { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: true }, + ], + withoutData: [ + { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, + ], + }, + ); + }); + test('hides customizations and subagents by default, and always shows changes', () => { const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); @@ -89,7 +108,6 @@ suite('SessionChatPills', () => { issues: visibility.isVisible(SessionChatPillKind.Issues, undefined), restored: disposables.add(new SessionChatPillVisibility(storageService)).isVisible(SessionChatPillKind.PullRequests, undefined), }; - // Hiding an already-hidden pill is a no-op, so one toggle brings it back. visibility.hide(SessionChatPillKind.PullRequests); visibility.toggle(SessionChatPillKind.PullRequests); diff --git a/src/vs/workbench/test/browser/chatPills.test.ts b/src/vs/workbench/test/browser/chatPills.test.ts new file mode 100644 index 00000000000000..e5a69d4ff4061d --- /dev/null +++ b/src/vs/workbench/test/browser/chatPills.test.ts @@ -0,0 +1,482 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getWindow } from '../../../base/browser/dom.js'; +import { ensureCodeWindow, mainWindow } from '../../../base/browser/window.js'; +import type { IManagedHoverContent } from '../../../base/browser/ui/hover/hover.js'; +import { timeout } from '../../../base/common/async.js'; +import { Action } from '../../../base/common/actions.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { constObservable, derived, observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { mock } from '../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IActionListDelegate, IActionListItem } from '../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../platform/actionWidget/browser/actionWidget.js'; +import { ChatDropdownPillActionViewItem, ChatPillSingleEntry, createChatSectionPill } from '../../browser/chatDropdownPill.js'; +import { ChatPillsRow, ChatPillsWidget, type IChatPill, type IChatPillEntry, type IChatPillSection } from '../../browser/chatPills.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../browser/labels.js'; +import { workbenchInstantiationService } from './workbenchTestServices.js'; + +const getDropdownPillHoverContents = Reflect.get(ChatDropdownPillActionViewItem.prototype, 'getHoverContents') as (this: ChatDropdownPillActionViewItem) => IManagedHoverContent; + +suite('ChatPills', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps an empty compact pill row keyboard-accessible', async () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.test', { compact: true })); + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + let contextMenuRequests = 0; + let contextMenuTarget: HTMLElement | undefined; + disposables.add(row.onDidRequestContextMenu(target => { + contextMenuRequests++; + contextMenuTarget = target; + })); + + row.setEmpty(true, 'Configure Session Status Pills'); + const event = new mainWindow.KeyboardEvent('keydown', { bubbles: true }); + Object.defineProperty(event, 'keyCode', { value: 13 }); + row.content.dispatchEvent(event); + row.restoreFocus(() => []); + await timeout(0); + const emptyState = { + compact: row.element.classList.contains('compact'), + role: row.content.getAttribute('role'), + ariaLabel: row.content.getAttribute('aria-label'), + ariaHasPopup: row.content.getAttribute('aria-haspopup'), + tabIndex: row.content.tabIndex, + contextMenuRequests, + focused: mainWindow.document.activeElement === row.content, + contextTarget: contextMenuTarget === row.content, + }; + const pill = mainWindow.document.createElement('button'); + row.content.appendChild(pill); + row.setEmpty(false, ''); + row.restoreFocus(() => [pill]); + await timeout(0); + + assert.deepStrictEqual({ + emptyState, + restored: { + role: row.content.getAttribute('role'), + ariaLabel: row.content.getAttribute('aria-label'), + ariaHasPopup: row.content.getAttribute('aria-haspopup'), + tabIndex: row.content.getAttribute('tabindex'), + pillFocused: mainWindow.document.activeElement === pill, + }, + }, { + emptyState: { + compact: true, + role: 'button', + ariaLabel: 'Configure Session Status Pills', + ariaHasPopup: 'menu', + tabIndex: 0, + contextMenuRequests: 1, + focused: true, + contextTarget: true, + }, + restored: { + role: null, + ariaLabel: null, + ariaHasPopup: null, + tabIndex: null, + pillFocused: true, + }, + }); + + disposables.dispose(); + }); + + test('uses the main DOM realm and target auxiliary window', () => { + const disposables = store.add(new DisposableStore()); + const iframe = mainWindow.document.createElement('iframe'); + mainWindow.document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + const auxiliaryWindow = iframe.contentWindow!; + ensureCodeWindow(auxiliaryWindow, 999); + + const row = disposables.add(new ChatPillsRow('ChatPills.auxiliaryWindowTest', { targetWindow: auxiliaryWindow })); + auxiliaryWindow.document.body.appendChild(row.element); + + assert.deepStrictEqual({ + contentDocument: row.content.ownerDocument === auxiliaryWindow.document, + elementDocument: row.element.ownerDocument === auxiliaryWindow.document, + contentUsesMainPrototype: Object.getPrototypeOf(row.content) === mainWindow.HTMLDivElement.prototype, + elementUsesMainPrototype: Object.getPrototypeOf(row.element) === mainWindow.HTMLDivElement.prototype, + windowId: getWindow(row.content).vscodeWindowId, + }, { + contentDocument: true, + elementDocument: true, + contentUsesMainPrototype: true, + elementUsesMainPrototype: true, + windowId: 999, + }); + + disposables.dispose(); + }); + + test('compact rows collapse pill details while retaining icons', () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.compactTest', { compact: true })); + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + + const button = mainWindow.document.createElement('button'); + button.className = 'monaco-button chat-pill-button chat-resource-pill-button'; + const item = mainWindow.document.createElement('div'); + item.className = 'chat-pill-item'; + const icon = mainWindow.document.createElement('span'); + icon.className = 'chat-pill-icon'; + const label = mainWindow.document.createElement('span'); + label.className = 'chat-pill-label'; + const counter = mainWindow.document.createElement('div'); + counter.className = 'monaco-animated-counter'; + const chevron = mainWindow.document.createElement('span'); + chevron.className = 'chat-pill-chevron'; + const resourceIcon = mainWindow.document.createElement('span'); + resourceIcon.className = 'chat-resource-pill-compact-icon'; + const resourceName = mainWindow.document.createElement('span'); + resourceName.className = 'monaco-icon-label'; + button.append(icon, label, counter, chevron, resourceIcon, resourceName); + item.appendChild(button); + row.content.appendChild(item); + + const compactState = { + iconVisible: mainWindow.getComputedStyle(icon).display !== 'none', + labelVisible: mainWindow.getComputedStyle(label).display !== 'none', + counterVisible: mainWindow.getComputedStyle(counter).display !== 'none', + chevronVisible: mainWindow.getComputedStyle(chevron).display !== 'none', + resourceIconVisible: mainWindow.getComputedStyle(resourceIcon).display !== 'none', + resourceNameVisible: mainWindow.getComputedStyle(resourceName).display !== 'none', + }; + row.element.classList.remove('compact'); + + assert.deepStrictEqual({ + compactState, + expandedResourceIconVisible: mainWindow.getComputedStyle(resourceIcon).display !== 'none', + }, { + compactState: { + iconVisible: true, + labelVisible: false, + counterVisible: false, + chevronVisible: false, + resourceIconVisible: true, + resourceNameVisible: false, + }, + expandedResourceIconVisible: false, + }); + + disposables.dispose(); + }); + + test('automatic compact mode follows available width', () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.responsiveTest', { compact: 'auto' })); + row.element.style.width = '600px'; + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + + const item = mainWindow.document.createElement('div'); + item.className = 'chat-pill-item'; + const button = mainWindow.document.createElement('button'); + button.className = 'monaco-button chat-pill-button'; + const icon = mainWindow.document.createElement('span'); + icon.className = 'chat-pill-icon'; + const label = mainWindow.document.createElement('span'); + label.className = 'chat-pill-label'; + label.textContent = 'A detailed pill label that needs room'; + button.append(icon, label); + item.appendChild(button); + row.content.appendChild(item); + + row.layout(); + const wideCompact = row.element.classList.contains('compact'); + row.element.style.width = '500px'; + row.layout(); + const mediumCompact = row.element.classList.contains('compact'); + row.element.style.width = '40px'; + row.layout(); + const narrowCompact = row.element.classList.contains('compact'); + row.element.style.width = '600px'; + row.layout(); + + assert.deepStrictEqual({ + wideCompact, + mediumCompact, + narrowCompact, + expandedAgain: !row.element.classList.contains('compact'), + }, { + wideCompact: false, + mediumCompact: false, + narrowCompact: true, + expandedAgain: true, + }); + + disposables.dispose(); + }); + + test('preserves existing pill DOM when membership changes', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const firstPill: IChatPill = { action: disposables.add(new Action('first', 'First')) }; + const secondPill: IChatPill = { action: disposables.add(new Action('second', 'Second')) }; + const pills = observableValue('chatPills.membership', [firstPill]); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const firstButton = widget.getPillElements()[0]; + firstButton.focus(); + + pills.set([firstPill, secondPill], undefined); + const afterAdd = widget.getPillElements(); + const focusPreservedAfterAdd = mainWindow.document.activeElement === firstButton; + pills.set([secondPill], undefined); + const afterRemove = widget.getPillElements(); + + assert.deepStrictEqual({ + afterAddCount: afterAdd.length, + firstPreservedAfterAdd: afterAdd[0] === firstButton, + focusPreservedAfterAdd, + afterRemoveCount: afterRemove.length, + remainingTabIndex: afterRemove[0].tabIndex, + focusMovedAfterRemove: mainWindow.document.activeElement === afterRemove[0], + }, { + afterAddCount: 2, + firstPreservedAfterAdd: true, + focusPreservedAfterAdd: true, + afterRemoveCount: 1, + remainingTabIndex: 0, + focusMovedAfterRemove: true, + }); + + disposables.dispose(); + }); + + test('keeps a section pill stable while its visible presentation is unchanged', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const entry = (id: string): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + open: () => { }, + }); + const sections = observableValue('chatPills.sections', [{ + title: 'Pull Requests', + entries: [entry('1'), entry('2')], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + singleEntry: ChatPillSingleEntry.InlineResource, + }, resourceLabels, instantiationService); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills: pill.map(value => [value]) }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const descriptor = pill.get(); + const button = widget.getPillElements()[0]; + const label = button.querySelector('.chat-pill-label'); + + sections.set([{ + title: 'Pull Requests', + entries: [ + { ...entry('1'), resource: URI.parse('https://github.com/microsoft/vscode/pull/1') }, + entry('2'), + ], + }], undefined); + + assert.deepStrictEqual({ + descriptorPreserved: pill.get() === descriptor, + buttonPreserved: widget.getPillElements()[0] === button, + labelPreserved: button.querySelector('.chat-pill-label') === label, + labelText: label?.textContent, + }, { + descriptorPreserved: true, + buttonPreserved: true, + labelPreserved: true, + labelText: '2 Pull Requests', + }); + + disposables.dispose(); + }); + + test('uses optional rich hover content only for an inline entry', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const richHover: IManagedHoverContent = { element: () => mainWindow.document.createElement('div') }; + const entry = (id: string, pillHover?: IManagedHoverContent): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + tooltip: `https://github.com/microsoft/vscode/pull/${id}`, + ...(pillHover !== undefined ? { pillHover } : {}), + open: () => { }, + }); + const sections = observableValue('chatPills.hoverSections', [{ + title: 'Pull Requests', + entries: [entry('1')], + }]); + const viewItem = disposables.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, action, {}, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + })); + const container = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + viewItem.render(container); + + const fallbackHover = getDropdownPillHoverContents.call(viewItem); + sections.set([{ title: 'Pull Requests', entries: [entry('1', richHover)] }], undefined); + const enrichedHover = getDropdownPillHoverContents.call(viewItem); + sections.set([{ title: 'Pull Requests', entries: [entry('1', richHover), entry('2')] }], undefined); + const summaryHover = getDropdownPillHoverContents.call(viewItem); + + assert.deepStrictEqual({ + fallbackHover, + usesRichHover: enrichedHover === richHover, + summaryHover, + }, { + fallbackHover: 'https://github.com/microsoft/vscode/pull/1', + usesRichHover: true, + summaryHover: 'Show 2 pull requests', + }); + + disposables.dispose(); + }); + + test('updates and closes an open section dropdown when entries change', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + let visible = false; + let onHide: ((didCancel?: boolean) => void) | undefined; + let shownLabels: readonly (string | undefined)[] = []; + let updatedLabels: readonly (string | undefined)[] = []; + let hideCount = 0; + const dropdownFocus = mainWindow.document.createElement('button'); + mainWindow.document.body.appendChild(dropdownFocus); + disposables.add(toDisposable(() => dropdownFocus.remove())); + const actionWidgetService = new class extends mock() { + override get isVisible(): boolean { return visible; } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + visible = true; + shownLabels = items.map(item => item.label); + onHide = delegate.onHide; + dropdownFocus.focus(); + } + override updateItems(items: readonly IActionListItem[]): void { + updatedLabels = items.map(item => item.label); + } + override hide(didCancel?: boolean): void { + hideCount++; + visible = false; + onHide?.(didCancel); + } + }(); + instantiationService.stub(IActionWidgetService, actionWidgetService); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const entry = (id: string): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + open: () => { }, + }); + const sections = observableValue('chatPills.openSections', [{ + title: 'Pull Requests', + entries: [entry('1'), entry('2')], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + }, resourceLabels, instantiationService); + const siblingPill: IChatPill = { action: disposables.add(new Action('issues', 'Issues')) }; + const includeSibling = observableValue('chatPills.includeSibling', false); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { + pills: derived(reader => [pill.read(reader), ...(includeSibling.read(reader) ? [siblingPill] : [])]), + }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const button = widget.getPillElements()[0]; + + button.click(); + sections.set([{ + title: 'Pull Requests', + entries: [entry('2'), entry('3')], + }], undefined); + includeSibling.set(true, undefined); + const expandedAfterUpdate = button.getAttribute('aria-expanded'); + const dropdownFocusPreserved = mainWindow.document.activeElement === dropdownFocus; + sections.set([], undefined); + + assert.deepStrictEqual({ + shownLabels, + updatedLabels, + expandedAfterUpdate, + dropdownFocusPreserved, + hideCount, + expandedAfterEmpty: button.getAttribute('aria-expanded'), + }, { + shownLabels: ['Pull Requests', 'Pull Request #1', 'Pull Request #2'], + updatedLabels: ['Pull Requests', 'Pull Request #2', 'Pull Request #3'], + expandedAfterUpdate: 'true', + dropdownFocusPreserved: true, + hideCount: 1, + expandedAfterEmpty: null, + }); + + disposables.dispose(); + }); + + test('exposes the description of an inline section entry', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequest', 'Pull Request')); + const sections = constObservable([{ + title: 'Pull Requests', + entries: [{ + id: '1', + label: 'Pull Request #1', + ariaLabel: 'Open Pull Request #1', + ariaDescription: 'merged. https://github.com/microsoft/vscode/pull/1', + open: () => { }, + }], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + }, resourceLabels, instantiationService); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills: pill.map(value => [value]) }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const button = widget.getPillElements()[0]; + + assert.deepStrictEqual({ + ariaLabel: button.getAttribute('aria-label'), + ariaDescription: button.getAttribute('aria-description'), + }, { + ariaLabel: 'Open Pull Request #1', + ariaDescription: 'merged. https://github.com/microsoft/vscode/pull/1', + }); + + disposables.dispose(); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index 2c391a73820e13..65f5843ac2ef27 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -74,6 +74,7 @@ import { IChatModeService } from '../../../../contrib/chat/common/chatModes.js'; import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { ISessionChatPillVisibilityService, SessionChatPillVisibility } from '../../../../contrib/chat/common/sessionChatPills.js'; import { Target } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { ILanguageModelsService } from '../../../../contrib/chat/common/languageModels.js'; import { ChatAgentService, IChatAgent, IChatAgentNameService, IChatAgentService } from '../../../../contrib/chat/common/participants/chatAgents.js'; @@ -144,6 +145,7 @@ export interface IChatFixtureServicesOptions { export function registerChatFixtureServices(reg: ServiceRegistration, options: IChatFixtureServicesOptions = {}): void { registerWorkbenchServices(reg); reg.define(IMenuService, FixtureMenuService); + reg.define(ISessionChatPillVisibilityService, SessionChatPillVisibility); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.define(IListService, ListService); reg.defineInstance(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index fddb30d9c0be55..f376a0d6d0dca7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -35,7 +35,7 @@ import { SessionType } from '../../../../contrib/chat/common/chatSessionsService import { IEditSessionEntryDiff } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatResponseFileChangesService, IChatResponseFileEdit } from '../../../../contrib/chat/browser/chatResponseFileChangesService.js'; import { MockChatService } from '../../../../contrib/chat/test/common/chatService/mockChatService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, type ServiceRegistration } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import { ChatTurnStatusPillsSetting, isChatTurnStatusPillsEnabled } from '../../../../contrib/chat/browser/widget/chatTurnPills.js'; import { ITerminalChatService } from '../../../../contrib/terminal/browser/terminal.js'; @@ -110,6 +110,8 @@ export interface IChatWidgetFixtureOptions { */ readonly turnStatusPills?: ChatTurnStatusPillsSetting; readonly linkPresentationService?: ILinkPresentationService; + /** Registers fixture-specific services after the shared chat service graph. */ + readonly additionalServices?: (registration: ServiceRegistration) => void; readonly onRendered?: (handle: IChatWidgetFixtureHandle) => void; /** Selects the input-height consumer used by the ResizeObserver harness. */ readonly hostLayoutMode?: 'none' | 'listOnly' | 'stackedFull' | 'stackedTargeted'; @@ -227,6 +229,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options override async assess(): Promise { return new Promise(() => { }); } }()); } + options.additionalServices?.(reg); }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts deleted file mode 100644 index 5a36834beedadf..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts +++ /dev/null @@ -1,255 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../../base/common/uri.js'; -import { toAction } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubInfo, IGitHubIssueRef, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { computeIssueIcon, GitHubIssueState, GitHubIssueStateReason, IGitHubIssue } from '../../../../../sessions/contrib/github/common/types.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; -// eslint-disable-next-line local/code-import-patterns -import { createIssueHoverElement } from '../../../../../sessions/contrib/github/browser/issueHover.js'; -// eslint-disable-next-line local/code-import-patterns -import { GitHubReferenceList } from '../../../../../sessions/contrib/github/browser/githubReferenceList.js'; -// eslint-disable-next-line local/code-import-patterns -import { OpenIssueActionViewItem } from '../../../../../sessions/contrib/github/browser/issueActions.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import { createFixtureGitHubService } from './githubFixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; -import '../../../../../base/browser/ui/actionbar/actionbar.css'; -import '../../../../../base/browser/ui/hover/hoverWidget.css'; -import '../../../../../platform/hover/browser/hover.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockWorkspace(issues: readonly IGitHubIssueRef[]): ISessionWorkspace { - const root = URI.file('/home/user/projects/vscode'); - const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', issues }; - - const gitRepository: ISessionGitRepository = { - uri: root, - workTreeUri: undefined, - baseBranchName: 'main', - gitHubInfo: constObservable(gitHubInfo), - }; - - const folder: ISessionFolder = { - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository, - }; - - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(issues: readonly IGitHubIssueRef[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = observableValue('workspace', createMockWorkspace(issues)); - }(); -} - -function toIssueRef(issue: IGitHubIssue): IGitHubIssueRef { - return { - owner: 'microsoft', - repo: 'vscode', - number: issue.number, - uri: URI.parse(`https://github.com/microsoft/vscode/issues/${issue.number}`), - }; -} - -// ============================================================================ -// Render helpers -// ============================================================================ - -function renderIssuePill(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { - const { container, disposableStore } = ctx; - - const session = observableValue('session', createMockSession(issues.map(toIssueRef))); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - reg.defineInstance(IGitHubService, createFixtureGitHubService([], issues.map(issue => ({ owner: 'microsoft', repo: 'vscode', issue })))); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.openIssue', title: 'Open Issue' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(OpenIssueActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -function renderInHoverWidget(ctx: ComponentFixtureContext, content: HTMLElement, width: string): void { - const { container } = ctx; - - container.style.padding = '24px'; - container.style.width = width; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - - const hover = document.createElement('div'); - hover.classList.add('monaco-hover', 'workbench-hover'); - hover.style.position = 'static'; - hover.style.display = 'inline-block'; - - const row = document.createElement('div'); - row.classList.add('hover-row', 'markdown-hover'); - hover.appendChild(row); - - const contents = document.createElement('div'); - contents.classList.add('hover-contents', 'html-hover-contents'); - contents.appendChild(content); - row.appendChild(contents); - - container.appendChild(hover); -} - -function renderIssueHover(ctx: ComponentFixtureContext, issue: IGitHubIssue): void { - renderInHoverWidget(ctx, createIssueHoverElement({ - owner: 'microsoft', - repo: 'vscode', - number: issue.number, - repositoryHref: 'https://github.com/microsoft/vscode', - issue, - }), '580px'); -} - -function renderIssueList(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { - const list = ctx.disposableStore.add(new GitHubReferenceList(issues.map(issue => ({ - number: issue.number, - title: issue.title, - icon: computeIssueIcon(issue.state, issue.stateReason), - toolbarActions: [toAction({ - id: 'fixture.copyIssueLink', - label: 'Copy Issue Link', - class: ThemeIcon.asClassName(Codicon.copy), - run: () => { }, - })], - })), () => { })); - renderInHoverWidget(ctx, list.element, '480px'); -} - -// ============================================================================ -// Data -// ============================================================================ - -const openIssue: IGitHubIssue = { - number: 12345, - title: 'Terminal hangs when running a long build task in a detached worktree', - body: 'Steps to reproduce: open a session on a worktree, start `npm run watch`, then switch to another session. The terminal stops streaming output and the task never reports completion.', - state: GitHubIssueState.Open, - stateReason: undefined, - author: { login: 'hariharjeevan', avatarUrl: '' }, - createdAt: '2026-06-22T10:00:00Z', - updatedAt: '2026-06-24T12:00:00Z', - closedAt: undefined, -}; - -const shortDescriptionIssue: IGitHubIssue = { - ...openIssue, - body: 'The terminal stops streaming output.', -}; - -const longDescriptionIssue: IGitHubIssue = { - ...openIssue, - body: 'Steps to reproduce: open a session on a worktree, start a long-running build, and switch to another session while output is still streaming. Return to the original session and observe that the terminal no longer updates even though the task is still running. The task also never reports completion, so it is unclear whether the build finished, failed, or remains active in the background. This description is intentionally long enough to exceed three lines and verify that the issue hover clamps the text without revealing any part of a fourth line.', -}; - -const completedIssue: IGitHubIssue = { - number: 678, - title: 'Session header pill should show the referenced issue', - body: 'The session header already surfaces the pull request. It should do the same for the GitHub issues the user referenced in their messages.', - state: GitHubIssueState.Closed, - stateReason: GitHubIssueStateReason.Completed, - author: { login: 'alex', avatarUrl: '' }, - createdAt: '2026-06-05T10:00:00Z', - updatedAt: '2026-06-18T09:30:00Z', - closedAt: '2026-06-18T09:30:00Z', -}; - -const notPlannedIssue: IGitHubIssue = { - number: 42, - title: 'Add a setting to disable issue detection entirely, including for cross-repository references', - body: 'Not planned — the pill is already scoped to explicit references.', - state: GitHubIssueState.Closed, - stateReason: GitHubIssueStateReason.NotPlanned, - author: { login: 'alex', avatarUrl: '' }, - createdAt: '2026-05-30T10:00:00Z', - updatedAt: '2026-06-02T08:00:00Z', - closedAt: '2026-06-02T08:00:00Z', -}; - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - OpenIssue_Single: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [openIssue]), - }), - - OpenIssue_Closed: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [completedIssue]), - }), - - OpenIssue_Multiple: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [openIssue, completedIssue, notPlannedIssue]), - }), - - OpenIssue_Hover: defineComponentFixture({ - render: (ctx) => renderIssueHover(ctx, shortDescriptionIssue), - }), - - OpenIssue_Hover_LongDescription: defineComponentFixture({ - render: (ctx) => renderIssueHover(ctx, longDescriptionIssue), - }), - - OpenIssue_List: defineComponentFixture({ - render: (ctx) => renderIssueList(ctx, [openIssue, completedIssue, notPlannedIssue]), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts deleted file mode 100644 index c3b7fb88c08795..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts +++ /dev/null @@ -1,271 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../../base/common/uri.js'; -import { toAction } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; -import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubInfo, IGitHubPullRequestRef, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; -// eslint-disable-next-line local/code-import-patterns -import { computePullRequestIcon, IGitHubPullRequest, GitHubPullRequestState } from '../../../../../sessions/contrib/github/common/types.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; -// eslint-disable-next-line local/code-import-patterns -import { createPullRequestHoverElement } from '../../../../../sessions/contrib/github/browser/pullRequestHover.js'; -// eslint-disable-next-line local/code-import-patterns -import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IPullRequestIconCache } from '../../../../../sessions/contrib/github/browser/pullRequestIconCache.js'; -// eslint-disable-next-line local/code-import-patterns -import { GitHubReferenceList } from '../../../../../sessions/contrib/github/browser/githubReferenceList.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import { createFixtureGitHubService, createFixturePullRequestIconCache } from './githubFixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; -import '../../../../../base/browser/ui/actionbar/actionbar.css'; -import '../../../../../base/browser/ui/hover/hoverWidget.css'; -import '../../../../../platform/hover/browser/hover.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockWorkspace(pullRequest: IGitHubInfo['pullRequest'], pullRequests: readonly IGitHubPullRequestRef[]): ISessionWorkspace { - const root = URI.file('/home/user/projects/vscode'); - const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', pullRequest, pullRequests }; - - const gitRepository: ISessionGitRepository = { - uri: root, - workTreeUri: undefined, - baseBranchName: 'main', - gitHubInfo: constObservable(gitHubInfo), - }; - - const folder: ISessionFolder = { - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository, - }; - - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(pullRequest: IGitHubInfo['pullRequest'], pullRequests: readonly IGitHubPullRequestRef[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = observableValue('workspace', createMockWorkspace(pullRequest, pullRequests)); - }(); -} - -// ============================================================================ -// Render helper -// ============================================================================ - -function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHubInfo['pullRequest'], pullRequestDetails: readonly IGitHubPullRequest[]): void { - const { container, disposableStore } = ctx; - - const pullRequests = pullRequestDetails.map(details => ({ - owner: 'microsoft', - repo: 'vscode', - number: details.number, - uri: URI.parse(`https://github.com/microsoft/vscode/pull/${details.number}`), - })); - const session = observableValue('session', createMockSession(pullRequest, pullRequests)); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - reg.defineInstance(ISessionsProvidersService, new class extends mock() { - override readonly onDidChangeProviders = Event.None; - override getProvider() { return undefined; } - }()); - reg.defineInstance(IGitHubService, createFixtureGitHubService(pullRequestDetails.map(details => ({ owner: 'microsoft', repo: 'vscode', pullRequest: details })))); - reg.defineInstance(IPullRequestIconCache, createFixturePullRequestIconCache()); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.openPullRequest', title: 'Open Pull Request' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(OpenPullRequestActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -function renderPullRequestList(ctx: ComponentFixtureContext, pullRequests: readonly IGitHubPullRequest[]): void { - const list = ctx.disposableStore.add(new GitHubReferenceList(pullRequests.map(pullRequest => ({ - number: pullRequest.number, - title: pullRequest.title, - icon: computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state), - toolbarActions: [toAction({ - id: 'fixture.copyPullRequestLink', - label: 'Copy Pull Request Link', - class: ThemeIcon.asClassName(Codicon.copy), - run: () => { }, - })], - })), () => { })); - renderInHoverWidget(ctx, list.element, '480px'); -} - -function renderPullRequestHover(ctx: ComponentFixtureContext, pullRequest: IGitHubPullRequest): void { - renderInHoverWidget(ctx, createPullRequestHoverElement({ - owner: 'microsoft', - repo: 'vscode', - number: pullRequest.number, - repositoryHref: 'https://github.com/microsoft/vscode', - pullRequest, - }), '580px'); -} - -function renderInHoverWidget(ctx: ComponentFixtureContext, content: HTMLElement, width: string): void { - const { container } = ctx; - - container.style.padding = '24px'; - container.style.width = width; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - const hover = document.createElement('div'); - hover.classList.add('monaco-hover', 'workbench-hover'); - hover.style.position = 'static'; - hover.style.display = 'inline-block'; - - const row = document.createElement('div'); - row.classList.add('hover-row', 'markdown-hover'); - hover.appendChild(row); - - const contents = document.createElement('div'); - contents.classList.add('hover-contents', 'html-hover-contents'); - contents.appendChild(content); - row.appendChild(contents); - - container.appendChild(hover); -} - -const openPr: IGitHubInfo['pullRequest'] = { - number: 12345, - uri: URI.parse('https://github.com/microsoft/vscode/pull/12345'), - icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, -}; - -const draftPr: IGitHubInfo['pullRequest'] = { - number: 678, - uri: URI.parse('https://github.com/microsoft/vscode/pull/678'), - icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, -}; - -const openPullRequestDetails: IGitHubPullRequest = { - number: openPr.number, - title: 'fix: suppress expected EPIPE error on graceful client disconnect', - body: 'Problem On every graceful client disconnect, the server logs an [error] Error: Unexpected EPIPE. This makes the expected disconnect path look like a real server failure and makes log scanning noisy for people investigating connection issues.', - state: GitHubPullRequestState.Open, - author: { login: 'hariharjeevan', avatarUrl: '' }, - headRef: 'fix-suppress-expected-epipe-error', - headSha: 'abc123', - baseRef: 'main', - isDraft: false, - createdAt: '2026-06-22T10:00:00Z', - updatedAt: '2026-06-22T12:00:00Z', - mergedAt: undefined, - mergeable: true, - mergeableState: 'clean', -}; - -const shortDescriptionPullRequest: IGitHubPullRequest = { - ...openPullRequestDetails, - body: 'Suppresses the expected EPIPE error on graceful disconnect.', -}; - -const longDescriptionPullRequest: IGitHubPullRequest = { - ...openPullRequestDetails, - body: 'Every graceful client disconnect currently logs an unexpected EPIPE error. This makes a routine shutdown look like a server failure and adds noise for anyone scanning logs while investigating connection issues. The change recognizes the expected disconnect path and avoids reporting it as an error while preserving diagnostics for unexpected failures. This description is intentionally long enough to exceed three lines and verify that the pull request hover clamps the text without revealing any part of a fourth line.', -}; - -const draftPullRequestDetails: IGitHubPullRequest = { - ...openPullRequestDetails, - number: draftPr.number, - title: 'draft: add session PR hover content', - body: 'Adds the first pass of the session header pull request hover with intentionally long branch names so truncation can be reviewed in component fixtures.', - state: GitHubPullRequestState.Open, - headRef: 'users/alex/very-long-session-pr-hover-fixture-branch-name', - isDraft: true, - createdAt: '2026-06-05T10:00:00Z', -}; - -const mergedPullRequestDetails: IGitHubPullRequest = { - ...openPullRequestDetails, - number: 42, - title: 'refactor: share the GitHub reference picker row', - state: GitHubPullRequestState.Merged, - headRef: 'refactor/github-reference-list', - createdAt: '2026-05-15T10:00:00Z', - mergedAt: '2026-05-18T09:30:00Z', -}; - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - OpenPullRequest_Open: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, openPr, [openPullRequestDetails]), - }), - - OpenPullRequest_Draft: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, draftPr, [draftPullRequestDetails]), - }), - - OpenPullRequest_Multiple: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, openPr, [openPullRequestDetails, draftPullRequestDetails, mergedPullRequestDetails]), - }), - - OpenPullRequest_Hover: defineComponentFixture({ - render: (ctx) => renderPullRequestHover(ctx, shortDescriptionPullRequest), - }), - - OpenPullRequest_Hover_LongDescription: defineComponentFixture({ - render: (ctx) => renderPullRequestHover(ctx, longDescriptionPullRequest), - }), - - OpenPullRequest_List: defineComponentFixture({ - render: (ctx) => renderPullRequestList(ctx, [openPullRequestDetails, draftPullRequestDetails, mergedPullRequestDetails]), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index d0028c6a387714..efecf2def87d25 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -5,11 +5,14 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { Event } from '../../../../../base/common/event.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; +import { computePullRequestIcon } from '../../../../common/chatPullRequest.js'; import { chatPersistentContentVisibleClass } from '../../../../contrib/chat/browser/widget/chatWidget.js'; import { BrowserEditorInput } from '../../../../contrib/browserView/common/browserEditorInput.js'; import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../contrib/browserView/common/browserView.js'; @@ -22,16 +25,22 @@ import { ISessionChatPillsDebugData } from '../../../../../sessions/contrib/chat // eslint-disable-next-line local/code-import-patterns import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; // eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestModel.js'; +// eslint-disable-next-line local/code-import-patterns import { SessionInputBanners } from '../../../../../sessions/contrib/sessionInputBanners/browser/sessionInputBanners.js'; // eslint-disable-next-line local/code-import-patterns import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../sessions/common/agentHostSessionsProvider.js'; // eslint-disable-next-line local/code-import-patterns -import { ChatOriginKind, ISessionArtifact, ISessionChangeset, ISessionChatCustomization, ISessionTurnFileChange, ISessionWorkspace, IChat, ISessionCapabilities, ISessionFileChange, SessionArtifactKind, SessionCustomizationKind, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +import { IAgentWorkbenchLayoutService } from '../../../../../sessions/browser/workbench.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionChangesService } from '../../../../../sessions/contrib/changes/browser/sessionChangesService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ChatOriginKind, type IGitHubInfo, type IGitHubPullRequestRef, ISessionArtifact, ISessionChangeset, ISessionChatCustomization, ISessionTurnFileChange, ISessionWorkspace, IChat, ISessionCapabilities, ISessionFileChange, ISessionFolder, ISessionGitRepository, SessionArtifactKind, SessionCustomizationKind, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, type ServiceRegistration } from '../fixtureUtils.js'; import { registerChatFixtureServices } from '../chat/chatFixtureUtils.js'; import { IFixtureMessage, renderChatWidget } from '../chat/chatWidget.fixture.js'; @@ -62,6 +71,7 @@ interface ISessionSpec { readonly artifacts?: readonly ISessionArtifact[]; /** Customizations the chat used or read. */ readonly customizations?: readonly ISessionChatCustomization[]; + readonly pullRequests?: readonly IGitHubPullRequestRef[]; } /** A mock session + its viewed chat, as the toolbar consumes them. */ @@ -72,6 +82,33 @@ interface IMockSessionAndChat { } function createMockSession(spec: ISessionSpec): IMockSessionAndChat { + const workspaceRoot = URI.file('/repo'); + const gitHubInfo: IGitHubInfo | undefined = spec.pullRequests ? { + owner: 'microsoft', + repo: 'vscode', + pullRequests: spec.pullRequests, + } : undefined; + const gitRepository: ISessionGitRepository | undefined = gitHubInfo ? { + uri: workspaceRoot, + workTreeUri: undefined, + baseBranchName: 'main', + gitHubInfo: constObservable(gitHubInfo), + } : undefined; + const folder: ISessionFolder = { + root: workspaceRoot, + workingDirectory: workspaceRoot, + name: 'vscode', + description: undefined, + gitRepository, + }; + const workspace: ISessionWorkspace = { + uri: workspaceRoot, + label: 'vscode', + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; const chat = new class extends mock() { override readonly resource = URI.parse('chat:1'); override readonly title = constObservable('Main chat'); @@ -96,8 +133,8 @@ function createMockSession(spec: ISessionSpec): IMockSessionAndChat { override readonly isArchived = constObservable(false); override readonly isRead = constObservable(true); override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: false }); - override readonly workspace: IObservable = constObservable(undefined); - override readonly changes: IObservable = constObservable([]); + override readonly workspace: IObservable = constObservable(workspace); + override readonly changes: IObservable = constObservable(spec.turnChanges ?? []); override readonly changesets: IObservable = constObservable([]); override readonly artifacts: IObservable = constObservable(spec.artifacts ?? []); }(); @@ -125,11 +162,40 @@ function createBrowserViewService(inputs: readonly BrowserEditorInput[]): IBrows }(); } +function registerSessionChatPillFixtureServices(registration: ServiceRegistration, sessionMock: IMockSessionAndChat): void { + registration.defineInstance(ISessionsProvidersService, new class extends mock() { + override getProvider() { return undefined; } + }()); + registration.defineInstance(IBrowserViewWorkbenchService, createBrowserViewService(sessionMock.browsers)); + registration.defineInstance(IAgentWorkbenchLayoutService, new class extends mock() { + override revealEditorPartExplicitly(): void { } + }()); + registration.defineInstance(ISessionChangesService, new class extends mock() { + override async openChangesEditor(): Promise { return undefined; } + }()); + registration.defineInstance(IGitHubService, new class extends mock() { + override readonly activeSessionPullRequestObs = constObservable(undefined); + override readonly activeSessionPullRequestCIObs = constObservable(undefined); + override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); + override createPullRequestModelReference(owner: string, repo: string, prNumber: number) { + const model = new class extends mock() { + override readonly pullRequest = constObservable(undefined); + override readonly owner = owner; + override readonly repo = repo; + override readonly prNumber = prNumber; + override refresh(): Promise { return Promise.resolve(); } + override startPolling() { return Disposable.None; } + }(); + return { object: model, dispose: () => { } }; + } + }()); +} + // ============================================================================ // Render helpers // ============================================================================ -function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndChat, options?: { readonly debugData?: ISessionChatPillsDebugData; readonly enabled?: boolean; readonly width?: string }): void { +function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndChat, options?: { readonly compact?: boolean | 'auto'; readonly debugData?: ISessionChatPillsDebugData; readonly enabled?: boolean; readonly width?: string }): void { const { container, disposableStore } = ctx; const instantiationService = createEditorServices(disposableStore, { @@ -140,16 +206,8 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC // services) the artifact pill needs, on top of the base editor services // (which register a partial ISessionsService). registerChatFixtureServices(reg); - reg.defineInstance(ISessionsProvidersService, new class extends mock() { - override getProvider() { return undefined; } - }()); - reg.defineInstance(IBrowserViewWorkbenchService, createBrowserViewService(sessionMock.browsers)); + registerSessionChatPillFixtureServices(reg, sessionMock); if (options?.debugData) { - reg.defineInstance(IGitHubService, new class extends mock() { - override readonly activeSessionPullRequestObs = constObservable(undefined); - override readonly activeSessionPullRequestCIObs = constObservable(undefined); - override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); - }()); reg.defineInstance(IAgentFeedbackService, new class extends mock() { override readonly onDidChangeFeedback = Event.None; override readonly onDidChangeFeedbackVisibility = Event.None; @@ -165,7 +223,7 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, options?.enabled ?? true); - const pills = disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, options?.compact ?? false, undefined)); pills.setSession(sessionMock.session, sessionMock.chat); pills.setDebugData(options?.debugData); container.appendChild(pills.element); @@ -186,6 +244,7 @@ async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMock messages, height: options?.height, persistentContentHeight: SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, + additionalServices: registration => registerSessionChatPillFixtureServices(registration, mock), onRendered: scrollOffsetFromBottom ? handle => { const maximumScrollTop = Math.max(0, handle.listWidget.scrollHeight - handle.listWidget.renderHeight); @@ -198,7 +257,7 @@ async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMock instantiationService.invokeFunction(accessor => { (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); }); - const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); const updateChatPillsVisibility = (visible: boolean) => { inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); }; @@ -348,6 +407,34 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { })), }), + SessionChatPills_PullRequests: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + pullRequests: [{ + owner: 'microsoft', + repo: 'vscode', + number: 333, + uri: URI.parse('https://github.com/microsoft/vscode/pull/333'), + icon: computePullRequestIcon('open'), + state: 'open', + title: 'Keep stable pills', + }, { + owner: 'microsoft', + repo: 'vscode', + number: 222, + uri: URI.parse('https://github.com/microsoft/vscode/pull/222'), + icon: computePullRequestIcon('merged'), + state: 'merged', + }, { + owner: 'microsoft', + repo: 'vscode', + number: 111, + uri: URI.parse('https://github.com/microsoft/vscode/pull/111'), + icon: computePullRequestIcon('closed'), + state: 'closed', + }], + })), + }), + SessionChatPills_ArtifactsEveryType: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ artifacts: [ @@ -476,6 +563,44 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), }), + SessionChatPills_Compact: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [ + { id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }, + ], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: true, + width: '280px', + }), + }), + + SessionChatPills_ResponsiveWide: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: 'auto', + width: '600px', + }), + }), + + SessionChatPills_ResponsiveNarrow: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: 'auto', + width: '180px', + }), + }), + // --- Gating ------------------------------------------------------------- SessionChatPills_NotAgentHost_Hidden: defineComponentFixture({ @@ -525,11 +650,12 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { messages: FULL_VIEW_MESSAGES, inputVisible: false, persistentContentHeight: SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, + additionalServices: registration => registerSessionChatPillFixtureServices(registration, mock), decorateInputPart: (inputPart, instantiationService) => { instantiationService.invokeFunction(accessor => { (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); }); - const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); pills.setSession(mock.session, mock.chat); inputPart.persistentContentContainerElement.appendChild(pills.element); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts deleted file mode 100644 index 3936a82c1cebed..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts +++ /dev/null @@ -1,147 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../../base/common/uri.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { BRANCH_CHANGES_CHANGESET_ID, ISessionChangeset, ISessionFileChange, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { ViewAllChangesActionViewItem } from '../../../../../sessions/contrib/changes/browser/changesActions.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockChange(insertions: number, deletions: number): ISessionFileChange { - return { - modifiedUri: URI.file(`/repo/file-${Math.random().toString(36).slice(2)}.ts`), - insertions, - deletions, - }; -} - -function createMockBranchChangeset(changes: readonly ISessionFileChange[]): ISessionChangeset { - return new class extends mock() { - override readonly id = BRANCH_CHANGES_CHANGESET_ID; - override readonly changes: IObservable = constObservable(changes); - override readonly isEnabled: IObservable = constObservable(true); - override readonly isDefault: IObservable = constObservable(true); - }(); -} - -function createMockWorkspace(): ISessionWorkspace { - const root = URI.file('/repo'); - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [{ - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository: { - uri: root, - workTreeUri: undefined, - branchName: 'feature/session-changes', - baseBranchName: 'main', - hasGitHubRemote: false, - gitHubInfo: constObservable(undefined), - }, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(changes: readonly ISessionFileChange[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = constObservable(createMockWorkspace()); - override readonly changes: IObservable = observableValue('changes', changes); - override readonly changesets: IObservable = constObservable([createMockBranchChangeset(changes)]); - }(); -} - -// ============================================================================ -// Render helper -// ============================================================================ - -function renderDiffStats(ctx: ComponentFixtureContext, changes: readonly ISessionFileChange[]): void { - const { container, disposableStore } = ctx; - - const session = observableValue('session', createMockSession(changes)); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.viewChanges', title: 'View All Changes' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(ViewAllChangesActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - ViewAllChanges_SingleFile: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(12, 3)]), - }), - - ViewAllChanges_MultipleFiles: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [ - createMockChange(42, 7), - createMockChange(118, 64), - createMockChange(5, 0), - ]), - }), - - ViewAllChanges_OnlyInsertions: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(256, 0)]), - }), - - ViewAllChanges_OnlyDeletions: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(0, 89)]), - }), - - ViewAllChanges_NoChanges: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, []), - }), -}); From bd9e0e4c70b2183b03a1a9a1f6888cce9eb136ec Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:53:14 -0700 Subject: [PATCH 22/44] chore: remove stale error telemetry customizations (#334335) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/prompts/fix-error.prompt.md | 60 ----------------- .github/skills/fix-errors/SKILL.md | 99 ----------------------------- 2 files changed, 159 deletions(-) delete mode 100644 .github/prompts/fix-error.prompt.md delete mode 100644 .github/skills/fix-errors/SKILL.md diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md deleted file mode 100644 index e833fb07e1ef9f..00000000000000 --- a/.github/prompts/fix-error.prompt.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -agent: agent -description: 'Fix an unhandled error from the VS Code error telemetry dashboard' -argument-hint: Paste the GitHub issue URL for the error-telemetry issue -tools: ['edit', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/runTask', 'read/getTaskOutput', 'search/usages', 'read/problems', 'search/changes', 'execute/testFailure', 'todo', 'execute/runTests', 'web/fetch', 'web/githubRepo'] ---- - -The user has given you a GitHub issue URL for an unhandled error from the VS Code error telemetry dashboard. Fetch the issue to retrieve its details (error message, stack trace, hit count, affected users). - -Follow the `fix-errors` skill guidelines to fix this error. Key principles: - -1. **Read the error construction code first.** Before proposing any fix, search the codebase for where the error is constructed (the `new Error(...)` or custom error class instantiation). Read the surrounding code to understand: - - What conditions trigger the error (thresholds, validation checks, categorization logic) - - What parameters, classifications, or categories the error encodes - - What the intended meaning of each category is and what action each warrants - - Whether the error is a symptom of invalid data, a threshold-based warning, or a design-time signal - Use this understanding to determine the correct fix strategy. Do NOT assume what the error means from its message alone — the construction code is the source of truth. -2. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. -3. **Trace the data flow upward** through the call stack to find the producer of invalid data. -4. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. -5. **If the producer is identifiable**, fix it directly. - -After making changes, check for compilation errors via the build task and run relevant unit tests. - -## Submitting the Fix - -After the fix is validated (compilation clean, tests pass): - -1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). -2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. -3. **Push**: `git push -u origin `. -4. **Create a draft PR** with a description that includes these sections: - - **Summary**: A concise description of what was changed and why. - - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. - - **Trigger scenarios**: What user actions or system conditions cause this error to surface. - - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: - ```` - ```mermaid - sequenceDiagram - participant A as CallerComponent - participant B as MiddleLayer - participant C as LowLevelUtil - A->>B: someOperation(data) - B->>C: validate(data) - C-->>C: data is invalid - C->>B: throws "error message" - B->>A: unhandled error propagates - ``` - ```` - - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). - - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. -5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. - - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. - - If there are review comments, evaluate each one: - - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). - - If not applicable, leave a reply explaining why. - - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. - - **Re-run tests** after addressing review comments to confirm nothing regressed. - - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. -6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. diff --git a/.github/skills/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md deleted file mode 100644 index f2afd203619d03..00000000000000 --- a/.github/skills/fix-errors/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: fix-errors -description: Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors. ---- - -When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count. - -## Approach - -### 1. Do NOT fix at the crash site - -The error manifests at a specific line in the stack trace, but **the fix almost never belongs there**. Fixing at the crash site (e.g., adding a `typeof` guard in a `revive()` function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere. - -### 2. Trace the data flow upward through the call stack - -Read each frame in the stack trace from bottom to top. For each frame, understand: -- What data is being passed and what is expected -- Where that data originated (IPC message, extension API call, storage, user input, etc.) -- Whether the data could have been corrupted or malformed at that point - -The goal is to find the **producer of invalid data**, not the consumer that crashes on it. - -### 3. When the producer cannot be identified from the stack alone - -Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case: - -- **Enrich the error message** at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline. -- **Do NOT silently swallow the error** — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle. -- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site. - -### 4. When the producer IS identifiable - -Fix the producer directly: -- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs -- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as `UriComponents` objects, not as strings) - -## Example - -Given a stack trace like: -``` -at _validateUri (uri.ts) ← validation throws -at new Uri (uri.ts) ← constructor -at URI.revive (uri.ts) ← revive assumes valid UriComponents -at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process -``` - -**Wrong fix**: Add a `typeof` guard in `URI.revive` to return `undefined` for non-object input. This silences the error but the caller still expects a valid URI and will fail later. - -**Right fix (when producer is unknown)**: Enrich the error at the IPC handler level and in `_validateUri` itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example: -```typescript -// In the IPC handler — validate before revive -function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI { - if (data && typeof data !== 'object') { - throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`); - } - // ... -} - -// In _validateUri — include the scheme value -throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); -``` - -**Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. - -## Understanding error construction before fixing - -Before proposing any fix, **always find and read the code that constructs the error**. Search the codebase for the error class name or a unique substring of the error message. The construction code reveals: - -- **What conditions trigger the error** — thresholds, validation checks, state assertions -- **What classifications or categories the error encodes** — the error may have subtypes that require different fix strategies -- **What the error's parameters mean** — numeric values, ratios, or flags embedded in the message often encode diagnostic context -- **Whether the error is actionable** — some errors are threshold-based warnings where the threshold may be legitimately exceeded by design - -Use this understanding to determine the correct fix strategy. The construction code is the source of truth — do NOT assume what the error means from its message alone. - -### Example: Listener leak errors - -Searching for `ListenerLeakError` leads to `src/vs/base/common/event.ts`, where the construction code reveals: - -```typescript -const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; -const error = new ListenerLeakError(kind, message, topStack); -``` - -Reading this code tells you: -- The error has two categories based on a ratio -- **Dominated** (ratio > 30%): one code path accounts for most listeners → that code path is the problem, fix its disposal -- **Popular** (ratio ≤ 30%): many diverse code paths each contribute a few listeners → the identified stack trace is NOT the root cause; it's just the most identical stack among many. Investigate the emitter and its aggregate subscribers instead -- For popular leaks: do NOT remove caching/pooling/reuse patterns that appear in the top stack — they exist to solve other problems. If the aggregate count is by design (e.g., many menus subscribing to a shared context key service), close the issue as "not planned" - -This analysis came from reading the construction code, not from memorized rules about listener leaks. - -## Guidelines - -- Prefer enriching error messages over adding try/catch guards -- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded) -- Do not change the behavior of shared utility functions (like `URI.revive`) in ways that affect all callers — fix at the specific call site or producer -- Run the relevant unit tests after making changes -- Check for compilation errors via the build task before declaring work complete From 71c72becc78a75b96b789fe2de819b2a30dda5df Mon Sep 17 00:00:00 2001 From: roblourens Date: Thu, 3 Sep 2026 11:57:10 -0700 Subject: [PATCH 23/44] test: stabilize empty Agent Host changesets (#334299) * test: stabilize empty Agent Host changesets Wait for the expected empty changeset notification instead of accepting an earlier transient recompute. This prevents the conformance test from observing a file between its restore write and the final checkpoint-backed refresh.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: fence empty changeset refreshes Subscribe to empty branch changesets only after the local turn completes. The resulting refresh is queued after stale setup recomputes, so the test observes a filesystem state that is causally after the restore.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/e2e/suites/changesetSuite.ts | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index f4365f8d07be95..aecccd0db49dbc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -250,6 +250,15 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return action.files.find(file => fileHasBasename(file, basename))!; } + async function waitForEmptyChangeset(channel: string): Promise { + await context.client.waitForNotification(n => + isActionNotification(n, 'changeset/contentChanged') + && getActionEnvelope(n).channel === channel + && (getActionEnvelope(n).action as IContentChangedAction).files.length === 0, + 60_000, + ); + } + async function waitForTurnComplete(sessionUri: string, turnId: string): Promise { const chatUri = buildDefaultChatUri(sessionUri); await context.client.waitForNotification(n => @@ -620,15 +629,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { execSync('git commit -q -m "ignore generated log"', { cwd: workspace }); const sessionUri = await createSessionIn(workspace, 'changeset-ignored'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-ignored', writeFileCommand('ignored.log', 'ignored'), 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); @@ -639,15 +643,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace('ahp-changeset-create-delete-'); const sessionUri = await createSessionIn(workspace, 'changeset-create-delete'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-create-delete', '!node -e "const fs=require(\'fs\');fs.writeFileSync(\'temporary.txt\',\'temporary\');fs.unlinkSync(\'temporary.txt\')"', 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); @@ -658,15 +657,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace('ahp-changeset-edit-restore-'); const sessionUri = await createSessionIn(workspace, 'changeset-edit-restore'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-edit-restore', writeFileTwiceBase64Command('seed.txt', 'changed', 'seed\n'), 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); From 136f1020e618a510b7aa6cae2b61c2ee4930d71b Mon Sep 17 00:00:00 2001 From: roblourens Date: Thu, 3 Sep 2026 12:04:01 -0700 Subject: [PATCH 24/44] Prevent auth replay from blocking provider shutdown (#332824) * Run provider shutdown alongside auth replay (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix stale provider references after merge (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Finalize providers after auth replay (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostProviderService.ts | 22 ++++++-- .../node/agentHostProviderService.test.ts | 50 +++++++++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostProviderService.ts b/src/vs/platform/agentHost/node/agentHostProviderService.ts index 711e0078343286..ed43a3dbc99ba4 100644 --- a/src/vs/platform/agentHost/node/agentHostProviderService.ts +++ b/src/vs/platform/agentHost/node/agentHostProviderService.ts @@ -58,6 +58,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr private readonly _providerInitializers = new Set<(provider: IAgent) => IDisposable>(); private readonly _authenticationReplays = new Map>(); private _defaultProvider: AgentProvider | undefined; + private _shutdownStarted = false; private _shutdownPromise: Promise | undefined; constructor( @@ -77,7 +78,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr } registerProvider(provider: IAgent): void { - if (this._shutdownPromise) { + if (this._shutdownStarted) { throw new Error('Cannot register an agent provider after shutdown has started'); } if (this._providers.has(provider.id)) { @@ -219,14 +220,27 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr } shutdown(): Promise { - return this._shutdownPromise ??= this._shutdown(); + if (!this._shutdownPromise) { + this._shutdownStarted = true; + this._shutdownPromise = this._shutdown(); + } + return this._shutdownPromise; } private async _shutdown(): Promise { + const providers = [...this._providers.values()]; try { - await Promises.settled([...this._authenticationReplays.values()]); - await Promises.settled([...this._providers.values()].map(provider => provider.shutdown())); + await Promises.settled([ + Promises.settled([...this._authenticationReplays.values()]), + Promises.settled(providers.map(async provider => provider.shutdown())), + ]); } finally { + for (const provider of providers) { + this._providerRegistrations.deleteAndDispose(provider.id); + this._providers.deleteAndDispose(provider.id); + } + this._agents.set([], undefined); + this._defaultProvider = undefined; this._sessionToProvider.clear(); } } diff --git a/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts b/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts index 61ccff933906f4..5b4b02b9cd15cd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts @@ -21,10 +21,12 @@ class TestAuthenticationService extends AgentHostAuthenticationService { readonly replayedProviders: IAgent[] = []; readonly authenticateCalls: { params: AuthenticateParams; providers: readonly IAgent[] }[] = []; replayGate: DeferredPromise | undefined; + afterReplay: (() => void) | undefined; override async replay(provider: IAgent): Promise { this.replayedProviders.push(provider); await this.replayGate?.p; + this.afterReplay?.(); } override async authenticate(params: AuthenticateParams, providers: Iterable) { @@ -39,6 +41,8 @@ class TestProvider extends MockAgent { disposeCount = 0; shutdownCount = 0; shutdownError: Error | undefined; + onShutdown: (() => void) | undefined; + resourceActive = false; mcpRequests: { chat: URI; serverName: string; method: string; params: Record | undefined }[] = []; async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record | undefined): Promise { @@ -52,6 +56,7 @@ class TestProvider extends MockAgent { override async shutdown(): Promise { this.shutdownCount++; + this.onShutdown?.(); if (this.shutdownError) { throw this.shutdownError; } @@ -59,6 +64,7 @@ class TestProvider extends MockAgent { override dispose(): void { this.disposeCount++; + this.resourceActive = false; this._onMcpNotification.dispose(); super.dispose(); } @@ -290,33 +296,69 @@ suite('AgentHostProviderService', () => { assert.deepStrictEqual({ authenticateProviders: authentication.authenticateCalls[0].providers.map(provider => provider.id), shutdownCounts: [first.shutdownCount, second.shutdownCount], + disposeCounts: [first.disposeCount, second.disposeCount], }, { authenticateProviders: ['first', 'second'], shutdownCounts: [1, 1], + disposeCounts: [1, 1], }); }); - test('waits for authentication replay before shutdown', async () => { + test('starts provider shutdown while authentication replay is in flight', async () => { const { service, authentication } = createService(); const provider = new TestProvider('copilot'); + provider.shutdownError = new Error('shutdown failed'); authentication.replayGate = new DeferredPromise(); + authentication.afterReplay = () => provider.resourceActive = true; service.registerProvider(provider); const shutdown = service.shutdown(); + const shutdownRejected = assert.rejects(shutdown, /shutdown failed/); await Promise.resolve(); assert.deepStrictEqual({ replayedProviders: authentication.replayedProviders.map(provider => provider.id), shutdownCount: provider.shutdownCount, + disposeCount: provider.disposeCount, }, { replayedProviders: ['copilot'], - shutdownCount: 0, + shutdownCount: 1, + disposeCount: 0, }); const lateProvider = new TestProvider('late'); assert.throws(() => service.registerProvider(lateProvider), /shutdown has started/); lateProvider.dispose(); authentication.replayGate.complete(); - await shutdown; - assert.strictEqual(provider.shutdownCount, 1); + await shutdownRejected; + assert.deepStrictEqual({ + shutdownCount: provider.shutdownCount, + disposeCount: provider.disposeCount, + resourceActive: provider.resourceActive, + agents: service.agents.get(), + }, { + shutdownCount: 1, + disposeCount: 1, + resourceActive: false, + agents: [], + }); + }); + + test('rejects provider registration from synchronous shutdown callbacks', async () => { + const { service } = createService(); + const provider = new TestProvider('copilot'); + const lateProvider = new TestProvider('late'); + provider.onShutdown = () => assert.throws(() => service.registerProvider(lateProvider), /shutdown has started/); + service.registerProvider(provider); + + await service.shutdown(); + + assert.deepStrictEqual({ + providerDisposeCount: provider.disposeCount, + lateProviderDisposeCount: lateProvider.disposeCount, + }, { + providerDisposeCount: 1, + lateProviderDisposeCount: 0, + }); + lateProvider.dispose(); }); }); From 9f1c7bda782764e5f46786bd0c4a22f6029c33ce Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 15:19:11 -0400 Subject: [PATCH 25/44] chat: Mark consolidated remote workspaces experimental (#334342) make consolidated remote workspaces experimental Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workbench/contrib/chat/browser/chat.shared.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 03d38ce9451e57..2d40cb75055477 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -466,7 +466,7 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.APPLICATION, description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available."), - tags: ['preview'], + tags: ['experimental'], experiment: { mode: 'auto' }, }, [ChatConfiguration.SaveBeforeSend]: { From 60bb3f66133766d971e382983de73dda7d0638e4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 15:22:06 -0400 Subject: [PATCH 26/44] launch: Encode agent session titles for command safety (#334304) * Encode launched session titles for command safety Pass the originating session title as URL-safe Base64 so chat-derived cmd metacharacters never cross the Windows launcher shell boundary as raw text. Decode the value only inside the native workbench environment.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Terminate Node option parsing for session titles Prevent dash-prefixed session titles from being interpreted as Node options by the launch-time Base64URL encoder.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/launch/scripts/launch.ps1 | 3 ++- .agents/skills/launch/scripts/launch.sh | 3 ++- src/vs/platform/environment/common/argv.ts | 2 +- src/vs/platform/environment/node/argv.ts | 2 +- .../environment/electron-browser/environmentService.ts | 7 +++++-- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index 2ee956c7139c73..3ed2b72c30e23b 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -506,7 +506,8 @@ try { if ($agents) { $launchArgs.Add('--agents') if (-not [string]::IsNullOrWhiteSpace($sessionTitle)) { - $launchArgs.Add("--session-title=$sessionTitle") + $sessionTitleBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sessionTitle)).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $launchArgs.Add("--session-title-base64=$sessionTitleBase64") } } $launchArgs.Add("--user-data-dir=$destinationUdd") diff --git a/.agents/skills/launch/scripts/launch.sh b/.agents/skills/launch/scripts/launch.sh index ad7a1f4be96f2c..8e6ce2f4aa8325 100755 --- a/.agents/skills/launch/scripts/launch.sh +++ b/.agents/skills/launch/scripts/launch.sh @@ -221,7 +221,8 @@ fi if [[ "$AGENTS" == "1" ]]; then ARGS=("--agents" "${ARGS[@]}") if [[ -n "$SESSION_TITLE" ]]; then - ARGS+=("--session-title=$SESSION_TITLE") + SESSION_TITLE_BASE64=$(node -e 'process.stdout.write(Buffer.from(process.argv[1], "utf8").toString("base64url"))' -- "$SESSION_TITLE") + ARGS+=("--session-title-base64=$SESSION_TITLE_BASE64") fi fi if (( ${#EXTRA_ARGS[@]} )); then diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 3571749ab1e8f6..f6108fb1799d4d 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -55,7 +55,7 @@ export interface NativeParsedArgs { 'new-window'?: boolean; 'reuse-window'?: boolean; 'agents'?: boolean; - 'session-title'?: string; + 'session-title-base64'?: string; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 0cfada5200fe86..03fbc91d547c29 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -109,7 +109,7 @@ export const OPTIONS: OptionDescriptions> = { 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, 'agents': { type: 'boolean', cat: 'o', deprecates: ['sessions'], description: localize('agents', "Opens the agents window.") }, - 'session-title': { type: 'string' }, + 'session-title-base64': { type: 'string' }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 57b97aed040600..b4ffe6831bb64b 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -14,7 +14,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Schemas } from '../../../../base/common/network.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { joinPath } from '../../../../base/common/resources.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; +import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; export const INativeWorkbenchEnvironmentService = refineServiceDecorator(IEnvironmentService); @@ -155,7 +155,10 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } @memoize - get sessionTitle(): string | undefined { return this.configuration['session-title']; } + get sessionTitle(): string | undefined { + const encodedSessionTitle = this.configuration['session-title-base64']; + return encodedSessionTitle ? decodeBase64(encodedSessionTitle).toString() : undefined; + } constructor( private readonly configuration: INativeWindowConfiguration, From 6afe58be37a1fac9bfcc07b03dd2239f08877942 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 15:34:09 -0400 Subject: [PATCH 27/44] sessions: align new chat picker typography (#334307) Use consistent body text, base icon sizing, and tokenized spacing across new-session pills and their dropdowns. Fixes #333887. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/media/chatWidget.css | 12 +++++--- .../contrib/chat/browser/sessionTypePicker.ts | 2 +- .../chat/browser/sessionWorkspacePicker.ts | 6 ++-- .../test/browser/newChatWidget.fixture.ts | 2 +- .../blocks-ci-screenshots.md | 28 +++++++++---------- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 9d75adee29b29a..8ff7de17eda668 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -207,7 +207,7 @@ * model picker font in the input toolbar. */ .new-chat-widget-container .new-chat-bottom-container .action-label { height: 16px; - padding: 3px 8px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); font-size: var(--vscode-fontSize-label2, 11px); color: var(--vscode-icon-foreground); } @@ -283,7 +283,7 @@ border-radius: var(--vscode-cornerRadius-circle); box-sizing: border-box; color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-body2); + font-size: var(--vscode-fontSize-body1); line-height: var(--vscode-spacing-size160); } @@ -307,6 +307,10 @@ font-size: var(--vscode-codiconFontSize-compact); } +.action-widget .sessions-new-chat-picker-list .monaco-list-row.action .codicon { + font-size: var(--vscode-codiconFontSize); +} + .sessions-workspace-category-picker .sessions-chat-dropdown-label { margin-left: 0; } @@ -339,7 +343,7 @@ display: flex; align-items: center; height: 16px; - padding: 3px 7px 3px 7px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); background-color: transparent; border: none; color: var(--vscode-icon-foreground); @@ -425,7 +429,7 @@ display: inline-flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: var(--vscode-codiconFontSize-compact); margin-left: 2px; line-height: 1; width: 12px; diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 8f461f87826333..f10c272bb32158 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -538,7 +538,7 @@ export class SessionTypePicker extends Disposable { getAriaLabel: (element) => element.item?.groupLabel ? localize('sessionTypePicker.itemAriaLabel', "{0}, {1}", element.label ?? '', element.item.groupLabel) : (element.label ?? ''), getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), }, - { minWidth: 200 }, + { className: 'sessions-new-chat-picker-list', minWidth: 200 }, ); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index da0d4b525832fa..968174e11f85f3 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -719,8 +719,8 @@ export class WorkspacePicker extends Disposable { const showFilter = isConsolidatedWorkspacePicker || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter - ? { showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } - : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; + ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } + : { className: 'sessions-new-chat-picker-list', reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; } /** @@ -782,7 +782,7 @@ export class WorkspacePicker extends Disposable { const items = this._buildItems(); const listOptions = this._useConsolidatedRemoteWorkspaces() ? this._buildListOptions(items, undefined) - : { inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true }; + : { className: 'sessions-new-chat-picker-list', inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true }; return { items, listOptions }; }, delegate, diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 0e688274862205..a8a3794a8c2061 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -413,7 +413,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), NewSessionWorkspacePicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker.'], + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker. Pill and dropdown labels use the same body text size, and their leading icons use the same base icon size.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openWorkspacePicker: true }), }), NewSessionGitHubContextPicker: defineComponentFixture({ diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index acea33e6313ead..2a258fd3b745e6 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -211,46 +211,46 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0fe9bb7434b473795187b0545ccaed6aa77af4b7e9f90890dc8885c7f31ddebb) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/19b1ebb2ae6b03f3500181ac7d35fe20ccf4f0a3bcac413211f00f3db200a69b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2d61e65235b95e91d8db031868df3ca20a0be29ef4bc63527919d44d8b089949) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c2903c1bfd9364ff2dd3c6531a47a0b5c4ad11892c21c6db3357eb986c3ae568) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a1477a35797beb694904a063b139294c6df3984cebe968a15ee1bf3da0d0706) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/828f33b8392e74ac892e8339d6b5401e5e17b9aeb424863f0c8ca0ae5982a30c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b3d1e747d5fc68a1a0214e42302eb93f5fd1b0c05919839edc960c9a426a2935) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/704712c8157cbb1e8b05602c92f65941f869a455400a7057057fa03330424c03) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8e7ca1d9cf566e287afef9ccf116c60b1cce43b70924ab2dcfb2ce571d23ac48) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/08859e75e7f3a262ed2428c7b676881578fc8f62301f4f3ca63d79c21a1e6dd0) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/89de02c5250d1dfabbf7ce94fca5a7c87e480d3a7b67c376eb3afe38e7168443) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e896298aaf3e160015d98455ae350254c4acb40d32117b29d8fd8284923f0f44) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3ee80de3c6e3555e9bf2dec75a00acc06a5656a4bf3864b95b7ac7a16a5fe499) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e1d5b98be6a615e7e30e3f08e1b1e80219743fbe0b478ff68a0e7b08e234cf56) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6646486035136d6960d0e8547e01e572fdb9a2bd61652f75710e29003e9764ed) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e6b7c571945d3c311ab89529478340018496b9d4c75e13095b672c913cee52ee) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/070c122c652f70e4e01f4565b47ff936af668eee09a5928f59c15200c3029e2f) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/92dddf0febbf9a2e7b9d1a33940fefc624ba319f7bb7bd4d911743c535908392) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5019d89a75c5d2ed11af6ae8b5dc10c55e0d068380b77b9caff25b6610c1fc98) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4ad50b05198491cea5a200931e6070113ac5238ecf6a31185e4d13e6f9dacdfc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4807e11e1a7be9450a27705b9fc1c171d709a547d45edb0bbf2e513c16a9e4b1) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/690f680cfabfb2547ed86052c31788d7e49de198568de881c48c4929855762cc) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ef4a895e36499772c251ee00d72d765d084b5082430e430c99398b7a12761c7a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/269504f69602705f579c0c977052d04c390c067595f6552841123a11c6275106) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c6c33dcf1ba01a1cf19d97289c2699e2e0571aac0e220b6c2d71282a3dd4cf3a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/65edfaa8f1cc8246ca1b9ee2e35ba2efc3267db79ec9e419f9924362418c5027) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/695c9069e5791b7d24b24c1f9db4561755ff5694ad38370a7706426b6d38210a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/058cf4ff1b32a3e30c1550713c4fde7410339cd5fe896ce3fd840b45653b5d41) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/75cb51ad8b1f6ecb8c3891c2ee1d260a45d293c502bef1c33ce0df915c543aa0) From 0fd2d7cc97a54f7b7e9839650198b8e7684534e6 Mon Sep 17 00:00:00 2001 From: roblourens Date: Thu, 3 Sep 2026 12:35:02 -0700 Subject: [PATCH 28/44] test: avoid restoring archived session during E2E cleanup (#334346) Dispose the intentionally unloaded archived session directly after verifying persistence. This prevents generic cleanup from cold-restoring it under the five-second subscribe deadline.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/e2e/suites/sessionPersistenceSuite.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index f789ee53d7df0b..235f5e5be4fe49 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -18,7 +18,7 @@ import { ActionType, type ChatToolCallCompleteAction } from '../../../../common/ import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, ToolResultContentType, type ChatState, type SessionState, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { createRealSession, driveTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; -import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { fetchSessionWithChat, getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; @@ -232,6 +232,12 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) restored: true, isArchived: true, }); + + await context.client.call('disposeSession', { channel: sessionUri }, getAgentHostE2ETestTimeout(30_000, 90_000)); + const trackedIndex = createdSessions.indexOf(sessionUri); + if (trackedIndex >= 0) { + createdSessions.splice(trackedIndex, 1); + } }); const peerChatPersistenceEnabled = config.supportsMultipleChats From 4290bede3cbc24e3fe9c979b655cebdf3b4e5f6b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 3 Sep 2026 12:38:10 -0700 Subject: [PATCH 29/44] agent host: stop chat queues from wedging on a non-settling SDK RPC (#334338) * agent host: stop chat queues from wedging on a non-settling SDK RPC All chat operations for one chat serialize on a single per-chat sequencer. A control-plane RPC in that queue had no timeout, so an RPC that never settled kept the queue head forever. Every later send, model change, and abort stayed queued and never ran. The client showed a turn that stayed active with no response and no error. Abort shared the same queue, so Cancel could not recover the chat. - Bounds the short SDK control-plane RPCs at 30 seconds through a new `_awaitControlPlaneRpc` helper. This covers `session.setModel`, `rpc.agent.select`, `rpc.agent.deselect`, and `rpc.history.truncate`. A timeout now rejects the operation and lets the queue continue. Turn delivery through `session.send` stays unbounded, because a turn can correctly run for a long time. - Removes abort from the per-chat queue. Abort must interrupt a blocked queue, so it cannot wait behind the task it must cancel. - Adds an operation name to each `_queueChat` call and logs a warning if a queue task runs for more than 60 seconds. The warning names the operation that holds the queue. - Adds regression tests for the timeout, for a queued send that continues after a timeout, and for an abort that completes while a queue task is blocked. Fixes https://github.com/microsoft/vscode/issues/334159 (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: address review feedback on the chat queue wedge fix Three follow-ups from review of the queue-wedge fix: Taking abort off the chat queue meant an abort arriving while a chat was still materializing found no target and silently succeeded, so a Cancel during that window was lost and the queued send still dispatched. Aborts with no live session now record a pending intent (only while work is actually queued for that chat), and the send honours and clears it before dispatching. Timing out a control-plane RPC abandons the await but cannot cancel the in-flight request, so the SDK's model/agent/history state may no longer match what the session believes it applied. A timed-out session is now marked for resync and the next send discards and resumes it, so a late-settling request cannot mutate state a later operation depends on. The stall warning wrapped the whole sendMessage task, including the intentionally unbounded provider call, so any turn over 60s was reported as a stalled queue. Tasks now cancel the warning when they reach a legitimately unbounded phase, leaving it to cover only bounded setup and control-plane work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 81 +++++++++++++++--- .../node/copilot/copilotAgentSession.ts | 42 +++++++-- .../agentHost/test/node/copilotAgent.test.ts | 85 ++++++++++++++++++- .../test/node/copilotAgentSession.test.ts | 30 ++++++- 4 files changed, 217 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bc81eb16dabb49..b155ce51c9d0c6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -345,6 +345,7 @@ interface ICopilotAgentSessionIdentity { /** Stable empty host-customization snapshot used before the host publishes one. */ const NO_HOST_CUSTOMIZATIONS: readonly Customization[] = Object.freeze([]); +const CHAT_QUEUE_STALL_WARNING_MS = 60_000; /** Coordinates all per-session work, resumption, and teardown. */ class CopilotSessionLifetime { @@ -888,6 +889,15 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidChangeChatData = this._register(new Emitter()); readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; private readonly _sessionLifetimes = new Map(); + /** Outstanding queued chat operations, keyed by chat sequencer key. */ + private readonly _chatQueueDepth = new Map(); + /** + * Chats whose abort arrived before a live session existed. Aborting no longer + * waits on the chat queue (a wedged queue must not swallow a cancel), so an + * abort can land while a queued send is still materializing its session. The + * send honours and clears this before dispatching. + */ + private readonly _pendingChatAborts = new Set(); /** Provisional chats that defer SDK/session creation until the first send. */ private readonly _provisionalSessions = new Map(); private _shutdownPromise: Promise | undefined; @@ -3272,7 +3282,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resumeTurnOnce(chat: URI, turnId: string, operationContext: URI | IAgentChatContext, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { const context = this._resolveChatContext(chat, operationContext); const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext.clientTelemetryContext; - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'resumeTurn', async () => { const current = this._resolveChatContext(chat, operationContext); let entry = current.target ?? await this._ensureResolvedChatSession(current); if (!entry) { @@ -3974,7 +3984,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _sendMessageOnce(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext, clientTelemetryContext?: IAgentHostClientTelemetryContext): Promise { const context = this._resolveSendChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'sendMessage', async enterUnboundedPhase => { const current = this._resolveSendChatContext(chat, operationContext); await this._activeClients.get(current.configurationResource)?.pluginController.retryFailedClientSyncIfNeeded(); @@ -3998,7 +4008,7 @@ export class CopilotAgent extends Disposable implements IAgent { [...new Set(entry.appliedDisabledRootMcpServers)].sort(), [...new Set(currentDisabledRootMcpServers)].sort(), ); - if (entry && (rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh)) { + if (entry && (rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh || entry.requiresControlPlaneResync)) { this._logService.info(`[Copilot:${current.configurationId}] Session configuration changed, refreshing session. clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); // Finish disconnecting before resuming the SAME SDK session id with // the updated config. Routing is preserved so the session identity @@ -4032,6 +4042,16 @@ export class CopilotAgent extends Disposable implements IAgent { try { const sdkMode = this._resolveSdkMode(current.configurationResource); + // An abort that arrived while this send was still materializing its + // session had nothing to cancel at the time; honour it now rather + // than dispatching a turn the user already cancelled. + if (this._pendingChatAborts.delete(current.sequencerKey)) { + this._logService.info(`[Copilot:${current.configurationId}] Dropping send for a chat cancelled before its session materialized`); + entry.discardActiveTurn(); + return; + } + // The provider call runs the whole turn and is unbounded by design. + enterUnboundedPhase(); await entry.send(prompt, attachments, turnId, sdkMode, senderClientId, clientType, resolveAgentHostInstructions(operationContext), clientTelemetryContext, !!operationContext && !URI.isUri(operationContext) && operationContext.agentMergeTurn === true); } catch (err) { const errCode = (err as { code?: number })?.code; @@ -4118,7 +4138,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._provisionalSessions.get(context.configurationId)?.sdkSessionId === context.sdkSessionId) { return []; } - const entry = await this._queueChat(context.configurationId, context.sequencerKey, async () => { + const entry = await this._queueChat(context.configurationId, context.sequencerKey, 'getChatMessages', async () => { return this._ensureResolvedChatSession(this._resolveChatContext(chat, sessionOrContext)).catch(err => { if (err instanceof SessionWorkingDirectoryMissingError) { throw err; @@ -4186,9 +4206,17 @@ export class CopilotAgent extends Disposable implements IAgent { private async _abortSessionOnce(chat: URI, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { - await this._resolveChatContext(chat, operationContext).target?.abort(); - }); + if (!context.target) { + // No live session to abort. If work is still queued for this chat it + // will materialize a session and dispatch after this point, so record + // the intent instead of silently dropping the cancel. + if (this._chatQueueDepth.has(context.sequencerKey)) { + this._logService.info(`[Copilot:${context.configurationId}] Abort with no live session; recording pending abort: chat=${context.sequencerKey}`); + this._pendingChatAborts.add(context.sequencerKey); + } + return; + } + await context.target.abort(); } /** Creates a concrete chat backing immediately, optionally by importing history from another chat. */ @@ -4214,7 +4242,7 @@ export class CopilotAgent extends Disposable implements IAgent { // A fork reads the source's state, so it serializes against the source's // session unless it explicitly requests an independent queue. const queue = (task: () => Promise) => fork?.independentQueue - ? this._queueChat(sessionId, chatKey, task) + ? this._queueChat(sessionId, chatKey, 'createChat', task) : this._queueSession(forkSourceSessionId ?? sessionId, task); await queue(async () => { const existing = this._chatBackings.get(chatKey); @@ -4467,7 +4495,7 @@ export class CopilotAgent extends Disposable implements IAgent { const chatKey = chat.toString(); const initial = this._resolveChatContext(chat, operationContext); const configurationId = initial.configurationId; - return this._queueChat(configurationId, initial.sequencerKey, async () => { + return this._queueChat(configurationId, initial.sequencerKey, 'disposeChat', async () => { const current = this._resolveChatContext(chat, operationContext); const target = current.target; const backing = this._chatBackings.get(chatKey); @@ -4644,9 +4672,34 @@ export class CopilotAgent extends Disposable implements IAgent { return lifetime ? lifetime.queueSession(task) : Promise.reject(new CancellationError()); } - private _queueChat(sessionId: string, chatKey: string, task: () => Promise): Promise { + private _queueChat(sessionId: string, chatKey: string, operation: string, task: (enterUnboundedPhase: () => void) => Promise): Promise { const lifetime = this._getOrCreateSessionLifetime(sessionId); - return lifetime ? lifetime.queueChat(chatKey, task) : Promise.reject(new CancellationError()); + if (!lifetime) { + return Promise.reject(new CancellationError()); + } + this._chatQueueDepth.set(chatKey, (this._chatQueueDepth.get(chatKey) ?? 0) + 1); + return lifetime.queueChat(chatKey, async () => { + const stallWarning = disposableTimeout( + () => this._logService.warn(`[Copilot:${sessionId}] Chat queue task stalled: chat=${chatKey}, operation=${operation}`), + CHAT_QUEUE_STALL_WARNING_MS, + ); + try { + // A task that reaches a legitimately unbounded phase (an agent turn + // can run for many minutes) cancels the warning itself, so only + // bounded setup and control-plane work can be reported as stalled. + return await task(() => stallWarning.dispose()); + } finally { + stallWarning.dispose(); + } + }).finally(() => { + const remaining = (this._chatQueueDepth.get(chatKey) ?? 1) - 1; + if (remaining > 0) { + this._chatQueueDepth.set(chatKey, remaining); + } else { + this._chatQueueDepth.delete(chatKey); + this._pendingChatAborts.delete(chatKey); + } + }); } /** Returns the live session for an exact chat, resuming it if necessary. */ @@ -4731,7 +4784,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._provisionalSessions.get(sessionId)?.chat.toString() === chat.toString()) { return; } - await this._queueChat(resolved.configurationId, resolved.sequencerKey, async () => { + await this._queueChat(resolved.configurationId, resolved.sequencerKey, 'truncateChat', async () => { const current = this._resolveTruncateChatContext(chat, context); this._logService.info(`[Copilot:${sessionId}] Truncating chat ${chat.toString()}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); @@ -4776,7 +4829,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _changeModelOnce(chat: URI, model: ModelSelection, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'changeModel', async () => { const current = this._resolveChatContext(chat, operationContext); const longContextWindow = this._longContextWindowFor(model.id); const freeLongContext = this._isFreeLongContext(model.id); @@ -4850,7 +4903,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _changeAgentOnce(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'changeAgent', async () => { const current = this._resolveChatContext(chat, operationContext); const provisional = this._provisionalSessions.get(current.configurationId); if (provisional) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f68f078f57c7ce..50f83a3d115142 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -7,7 +7,7 @@ import type { CopilotSession, CurrentToolMetadata, ElicitationContext, Elicitati import { realpath as fsRealpath } from 'fs'; import { cp, rm } from 'fs/promises'; import { promisify } from 'util'; -import { DeferredPromise, firstParallel, raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, firstParallel, raceCancellation, raceTimeout, RunOnceScheduler, Sequencer, SequencerByKey, Throttler, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -398,6 +398,8 @@ function isCopilotSdkToolOutputTempFile(filePath: string, tmpDir: string): boole } const realpath = promisify(fsRealpath); +// A non-settling control RPC must not permanently block the per-chat sequencer. +const CONTROL_PLANE_RPC_TIMEOUT_MS = 30_000; function hasParentPathSegment(filePath: string): boolean { return filePath.split(/[\\/]/).includes('..'); @@ -486,6 +488,8 @@ export interface ICopilotAgentSessionOptions { readonly platform?: NodeJS.Platform; /** Resolves symlinks for plugin resource permission checks. */ readonly realpath?: (path: string) => Promise; + /** Overrides the control-plane RPC timeout for deterministic tests. */ + readonly controlPlaneRpcTimeoutMs?: number; } /** @@ -758,6 +762,8 @@ export class CopilotAgentSession extends Disposable { readonly sessionId: string; readonly resourceUri: URI; private readonly _ownerSessionUri: URI; + private readonly _controlPlaneRpcTimeoutMs: number; + private _controlPlaneDesynchronized = false; get ownerSessionUri(): URI { return this._ownerSessionUri; } /** @deprecated Compatibility alias for SDK callbacks; this is the exact persistence resource. */ get sessionUri(): URI { return this.resourceUri; } @@ -1125,6 +1131,7 @@ export class CopilotAgentSession extends Disposable { this._developmentErrorInjectionEnabled = options.enableDevelopmentErrorInjection ?? !product.commit; this.sessionId = options.rawSessionId; this._ownerSessionUri = options.sessionUri; + this._controlPlaneRpcTimeoutMs = options.controlPlaneRpcTimeoutMs ?? CONTROL_PLANE_RPC_TIMEOUT_MS; this.resourceUri = options.resource ?? options.sessionUri; this._slashCommandProvider = new CopilotSlashCommandProvider(() => this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }).then(c => c.commands), this._logService); this._chatChannelUri = options.chatChannelUri; @@ -1858,6 +1865,17 @@ export class CopilotAgentSession extends Disposable { return this._mcpLaunchConfigurationDirty; } + /** + * Set when a control-plane RPC timed out. Timing out abandons the await but + * cannot cancel the in-flight SDK request, so the model/agent/history state + * this session believes it applied may not match the SDK's. The next send + * discards and resumes the SDK session so a late-settling request lands on a + * session nothing is using rather than mutating live state. + */ + get requiresControlPlaneResync(): boolean { + return this._controlPlaneDesynchronized; + } + get appliedDisabledRootMcpServers(): readonly string[] { return this._launchPlan.disabledRootMcpServers ?? []; } @@ -3177,7 +3195,7 @@ export class CopilotAgentSession extends Disposable { async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); this._lastSeenModelId = model; - await this._wrapper.session.setModel(model, { reasoningEffort, contextTier }); + await this._awaitControlPlaneRpc('session.setModel', this._wrapper.session.setModel(model, { reasoningEffort, contextTier })); } /** @@ -3415,6 +3433,20 @@ export class CopilotAgentSession extends Disposable { } } + /** Bounds a short SDK control-plane RPC so it cannot wedge the owning chat queue. */ + private async _awaitControlPlaneRpc(operation: string, rpc: Promise): Promise { + const result = await raceTimeout(rpc.then(value => ({ value })), this._controlPlaneRpcTimeoutMs); + if (!result) { + // The request is still in flight and may still mutate SDK state, so + // mark the session for resync rather than continuing to use it. + this._controlPlaneDesynchronized = true; + const error = new Error(`[Copilot:${this.sessionId}] ${operation} timed out after ${this._controlPlaneRpcTimeoutMs}ms`); + this._logService.error(error, `[Copilot:${this.sessionId}] Control-plane RPC timed out: ${operation}`); + throw error; + } + return result.value; + } + /** * Selects (or clears) a custom agent on the live SDK session. * Mirrors the SDK's `rpc.agent.select` / `rpc.agent.deselect` pair. @@ -3424,7 +3456,7 @@ export class CopilotAgentSession extends Disposable { const name = agentName; this._logService.info(`[Copilot:${this.sessionId}] Selecting custom agent: ${name}`); try { - await this._wrapper.session.rpc.agent.select({ name }); + await this._awaitControlPlaneRpc('rpc.agent.select', this._wrapper.session.rpc.agent.select({ name })); } catch (err) { this._logService.error(err, `[Copilot:${this.sessionId}] rpc.agent.select failed: name=${name}`); throw err; @@ -3432,7 +3464,7 @@ export class CopilotAgentSession extends Disposable { } else { this._logService.info(`[Copilot:${this.sessionId}] Clearing custom agent selection`); try { - await this._wrapper.session.rpc.agent.deselect(); + await this._awaitControlPlaneRpc('rpc.agent.deselect', this._wrapper.session.rpc.agent.deselect()); } catch (err) { this._logService.error(err, `[Copilot:${this.sessionId}] rpc.agent.deselect failed`); throw err; @@ -6470,7 +6502,7 @@ export class CopilotAgentSession extends Disposable { */ async truncateAtEventId(eventId: string, keepTurnId?: string): Promise { this._logService.info(`[Copilot:${this.sessionId}] Truncating via SDK RPC at eventId=${eventId}`); - const result = await this._wrapper.session.rpc.history.truncate({ eventId }); + const result = await this._awaitControlPlaneRpc('rpc.history.truncate', this._wrapper.session.rpc.history.truncate({ eventId })); this._logService.info(`[Copilot:${this.sessionId}] SDK truncation removed ${result.eventsRemoved} events`); // Clean up stale turns from our DB so getNextTurnEventId doesn't diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index a431135c28cd6e..5e5881e518b8d4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -11,7 +11,7 @@ import { isCustomizationEnabled } from '../../common/customizationEnablement.js' import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, raceTimeout, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, toDisposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -9222,6 +9222,8 @@ suite('CopilotAgent', () => { readonly resets: { turnId: string; senderClientId: string | undefined }[]; readonly modelCalls: { id: string; effort: string | undefined; tier?: string | undefined }[]; readonly agentCalls: (string | undefined)[]; + aborted: number; + discardedTurns: number; readonly debugLogCalls: { outputDirectory: string; includeSessionLogs: boolean }[]; } @@ -9241,6 +9243,8 @@ suite('CopilotAgent', () => { resets: [], modelCalls: [], agentCalls: [], + aborted: 0, + discardedTurns: 0, debugLogCalls: [], }; const fake = { @@ -9259,6 +9263,8 @@ suite('CopilotAgent', () => { resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, async setModel(id: string, reasoningEffort?: string, contextTier?: string): Promise { rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier }); }, async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + async abort(): Promise { rec.aborted++; }, + discardActiveTurn(): void { rec.discardedTurns++; }, async collectDebugLogs(outputDirectory: URI, includeSessionLogs: boolean): Promise { rec.debugLogCalls.push({ outputDirectory: outputDirectory.toString(), includeSessionLogs }); return true; @@ -10349,6 +10355,83 @@ suite('CopilotAgent', () => { } }); + test('continues queued sends after a non-settling control-plane RPC times out', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'control-rpc-timeout'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + const neverSettles = new Promise(() => { }); + (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => { + if (await raceTimeout(neverSettles, 1) === undefined) { + throw new Error('rpc.agent.deselect timed out'); + } + }; + setPeerChatStub(agent, chat, target.fake); + + const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); + const send = agent.chats.sendMessage(chat, 'follow-up', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); + await assert.rejects(change, /rpc\.agent\.deselect timed out/); + await send; + + assert.deepStrictEqual(target.rec.sends, [{ prompt: 'follow-up', turnId: 'turn-1', mode: undefined, senderClientId: undefined }]); + } finally { + await disposeAgent(agent); + } + }); + + test('aborts while a chat queue task is blocked', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'abort-queue-bypass'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + const controlPlaneGate = new DeferredPromise(); + (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => controlPlaneGate.p; + setPeerChatStub(agent, chat, target.fake); + + const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); + await timeout(0); + await agent.chats.abort(chat, exactChatContext(session, chat)); + controlPlaneGate.complete(); + await change; + + assert.deepStrictEqual({ aborted: target.rec.aborted, agentCalls: target.rec.agentCalls }, { aborted: 1, agentCalls: [] }); + } finally { + await disposeAgent(agent); + } + }); + test('drops a queued send when abort arrives before the session materializes', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'abort-before-materialize'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + // Register the backing (which fixes the chat's sequencer key) but no + // live entry, modelling a chat whose session is still materializing. + chatBackings(agent).set(chat.toString(), { sdkSessionId: 'sdk-a' }); + chatScopes(agent).set(chat.toString(), session); + const materializeGate = new DeferredPromise(); + (agent as unknown as { _ensureResolvedChatSession(): Promise })._ensureResolvedChatSession = async () => { + await materializeGate.p; + return target.fake; + }; + + const send = agent.chats.sendMessage(chat, 'cancelled', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); + await timeout(0); + await agent.chats.abort(chat, exactChatContext(session, chat)); + materializeGate.complete(); + await send; + + assert.deepStrictEqual( + { sends: target.rec.sends, aborted: target.rec.aborted, discarded: target.rec.discardedTurns }, + { sends: [], aborted: 0, discarded: 1 }, + ); + } finally { + await disposeAgent(agent); + } + }); + test('round-trips addressed chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { // A single session data service is shared across the two agent // instances to model the on-disk store surviving a process restart. diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 2e2fc1381c23b7..c65d2d30af552b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -104,6 +104,9 @@ class MockCopilotSession { shellInitScriptUpdateSuccess = true; abortCalls = 0; abortGate: Promise | undefined; + modelGate: Promise | undefined; + agentSelectGate: Promise | undefined; + agentDeselectGate: Promise | undefined; readonly compactCalls: unknown[] = []; readonly commandListCalls: unknown[] = []; readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; @@ -262,7 +265,7 @@ class MockCopilotSession { this.abortCalls++; await this.abortGate; } - async setModel() { } + async setModel() { await this.modelGate; } async getEvents(): Promise { return this.messages; } async disconnect() { this.disconnectCalls++; @@ -274,6 +277,10 @@ class MockCopilotSession { } readonly rpc = { + agent: { + select: async () => { await this.agentSelectGate; }, + deselect: async () => { await this.agentDeselectGate; }, + }, sendMessages: async (request: unknown) => { this.sendMessagesRequests.push(request); if (this.sendMessagesError) { @@ -738,6 +745,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { sessionDatabase?: ISessionDatabase; /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ configureMockSession?: (session: MockCopilotSession) => void; + controlPlaneRpcTimeoutMs?: number; sessionCustomizations?: () => readonly Customization[]; resolveCustomizationEnablement?: (target: ICustomizationEnablementTarget) => CustomizationEnablementResolution; initialSessionMeta?: Record; @@ -1054,6 +1062,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { onTurnEnded: options?.onTurnEnded, enableDevelopmentErrorInjection: options?.enableDevelopmentErrorInjection ?? true, realpath: options?.realpath, + controlPlaneRpcTimeoutMs: options?.controlPlaneRpcTimeoutMs, }, )); @@ -1144,6 +1153,25 @@ suite('CopilotAgentSession', () => { }); }); + test('times out non-settling SDK control-plane RPCs', async () => { + const neverSettles = new Promise(() => { }); + const { session, mockSession } = await createAgentSession(disposables, { controlPlaneRpcTimeoutMs: 1 }); + mockSession.modelGate = neverSettles; + mockSession.agentSelectGate = neverSettles; + mockSession.agentDeselectGate = neverSettles; + + const results = await Promise.allSettled([ + session.setModel('test-model'), + session.setAgent('test-agent'), + session.setAgent(), + ]); + + assert.deepStrictEqual(results.map(result => result.status === 'rejected' ? (result.reason as Error).message : 'fulfilled'), [ + '[Copilot:test-session-1] session.setModel timed out after 1ms', + '[Copilot:test-session-1] rpc.agent.select timed out after 1ms', + '[Copilot:test-session-1] rpc.agent.deselect timed out after 1ms', + ]); + }); test('updates GitHub credentials through the SDK session RPC', async () => { const { session, mockSession } = await createAgentSession(disposables); await session.initializeSession(); From cec8ce7cfbdf878174f50cdf3a0c3b2541e19402 Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Thu, 3 Sep 2026 12:49:05 -0700 Subject: [PATCH 30/44] Implement subagent default setting to Auto without overriding models (#333663) * Agent Host changes for sbatten/agents/subagent-default-auto-setting * Fix subagent Auto model selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add subagent model selection telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Change subAgentInvocationId check to undefined Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: bhavyaus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../vscode-node/chatParticipants.ts | 7 +- .../chat/browser/chat.shared.contribution.ts | 9 + .../contrib/chat/common/constants.ts | 1 + .../tools/builtinTools/runSubagentTool.ts | 122 ++- .../builtinTools/runSubagentTool.test.ts | 818 ++++++++++++++---- 5 files changed, 757 insertions(+), 200 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts index 36228f7fa0846e..950ade9e2b320b 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts @@ -298,9 +298,12 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (!baseLmModel) { return request; } - await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: baseLmModel.vendor, id: baseLmModel.id, family: baseLmModel.family }); - // Switch to the base model and show a warning request = { ...request, model: baseLmModel }; + if (request.subAgentInvocationId === undefined) { + // A subagent runs inside the main request; changing the picker there would flip every widget mid-turn. + await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: baseLmModel.vendor, id: baseLmModel.id, family: baseLmModel.family }); + } + // Switch to the base model and show a warning let messageString: vscode.MarkdownString; if (this.authenticationService.copilotToken?.isIndividual) { messageString = new vscode.MarkdownString(vscode.l10n.t({ diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 2d40cb75055477..e0e7c0fd2411cd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -2418,6 +2418,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SubagentsDefaultToAuto]: { + type: 'boolean', + markdownDescription: nls.localize('chat.subagents.defaultToAuto', "Controls whether local subagents use the Auto model when neither the tool call nor the selected agent specifies a model. Explicit tool and agent model selections take precedence. Subagents of a BYOK main model continue to use that model. Auto routing is not constrained by the main model's fixed cost tier."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + }, + }, [ChatConfiguration.SubagentsUseRichRendering]: { type: 'boolean', description: nls.localize('chat.subagents.useRichRendering', "Controls whether subagents in chat editors use a rich presentation that opens each subagent in its own editor instead of rendering its full activity inline in the parent chat."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index c76aa5f6816c3a..7cb48c8f42ee5f 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -92,6 +92,7 @@ export enum ChatConfiguration { SessionStateIndicatorEnabled = 'chat.experimental.sessionStateIndicator.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', + SubagentsDefaultToAuto = 'chat.subagents.defaultToAuto', SubagentsUseRichRendering = 'chat.subagents.useRichRendering', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 7447e071477f76..14c305be1f4c5c 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -9,6 +9,7 @@ import { Emitter, type Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import type { URI } from '../../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; @@ -17,11 +18,12 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { isByokModel } from '../../chatSelectedModel.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; -import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; +import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import type { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { getChatSessionType } from '../../model/chatUri.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; @@ -65,6 +67,24 @@ export interface IRunSubagentToolInputParams { export const RUN_SUBAGENT_MAX_NESTING_DEPTH = 5; +type SubagentModelSelectionSource = 'explicitModel' | 'agentModel' | 'autoDefault' | 'mainModel'; + +interface IResolvedSubagentModel { + readonly modeModelId: string | undefined; + readonly resolvedModelName: string | undefined; + readonly selectionSource: SubagentModelSelectionSource; +} + +type SubagentModelSelectionEvent = { + selectionSource: SubagentModelSelectionSource; +}; + +type SubagentModelSelectionClassification = { + selectionSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source that selected the model for an invoked subagent. One of explicitModel, agentModel, autoDefault, or mainModel.' }; + owner: 'bhavyaus'; + comment: 'Tracks how the model for an invoked subagent was selected without collecting model or agent names.'; +}; + export class RunSubagentTool extends Disposable implements IToolImpl { static readonly Id = 'runSubagent'; @@ -73,11 +93,13 @@ export class RunSubagentTool extends Disposable implements IToolImpl { readonly onDidUpdateToolData: Event = this._onDidUpdateToolData.event; /** Hack to port data between prepare/invoke */ - private readonly _resolvedModels = new Map(); + private readonly _resolvedModels = new Map(); /** Tracks the current subagent nesting depth per session to detect and limit recursion. */ private readonly _sessionDepth = new Map(); + private _autoModelResolution: Promise | undefined; + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -88,8 +110,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @IPromptsService private readonly promptsService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IProductService private readonly productService: IProductService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => this._autoModelResolution = undefined)); } getToolData(): IToolData { @@ -165,6 +189,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeInstructions: IChatRequestModeInstructions | undefined; let subagent: ICustomAgent | undefined; let resolvedModelName: string | undefined; + let modelSelectionSource: SubagentModelSelectionSource = 'mainModel'; const currentModeInstructions = request.modeInfo?.modeInstructions; const subAgentName = this.normalizeRequestedAgentName(args.agentName); @@ -180,11 +205,13 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this._resolvedModels.delete(invocation.callId); modeModelId = cached.modeModelId; resolvedModelName = cached.resolvedModelName; + modelSelectionSource = cached.selectionSource; } else { // Fallback: resolve the model here if prepare didn't cache it - const resolved = this.resolveSubagentModel(subagent, invocation.modelId, args.model); + const resolved = await this.resolveSubagentModel(subagent, invocation.modelId, args.model); modeModelId = resolved.modeModelId; resolvedModelName = resolved.resolvedModelName; + modelSelectionSource = resolved.selectionSource; } // Use mode-specific tools if available @@ -223,10 +250,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this._resolvedModels.delete(invocation.callId); modeModelId = cached.modeModelId; resolvedModelName = cached.resolvedModelName; + modelSelectionSource = cached.selectionSource; } else { - const resolved = this.resolveSubagentModel(undefined, invocation.modelId, args.model); + const resolved = await this.resolveSubagentModel(undefined, invocation.modelId, args.model, currentModeInstructions); modeModelId = resolved.modeModelId; resolvedModelName = resolved.resolvedModelName; + modelSelectionSource = resolved.selectionSource; } } @@ -357,6 +386,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { })); // Invoke the agent, tracking nesting depth for recursion detection + this.telemetryService.publicLog2('chat.subagentModelSelection', { + selectionSource: modelSelectionSource, + }); this._sessionDepth.set(sessionKey, currentDepth + 1); let result: IChatAgentResult | undefined; try { @@ -456,12 +488,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { private getAvailableModelsInfo(mainModelId: string | undefined): string { const models = this.languageModelsService.getLanguageModelIds() .map(id => ({ id, metadata: this.languageModelsService.lookupLanguageModel(id) })) - .filter((m): m is { id: string; metadata: ILanguageModelChatMetadata } => - !!m.metadata - && ILanguageModelChatMetadata.suitableForAgentMode(m.metadata) - && m.metadata.isUserSelectable !== false - && !m.metadata.targetChatSessionType - ); + .filter((m): m is { id: string; metadata: ILanguageModelChatMetadata } => !!m.metadata && this.isSelectableForAgentMode(m.metadata)); if (models.length === 0) { return 'No models available.'; @@ -496,11 +523,16 @@ export class RunSubagentTool extends Disposable implements IToolImpl { * Resolves the model to be used by a subagent. * @param explicitModelQualifiedName Optional explicit model specified by the caller. * If provided and not found or not allowed, throws an error with available models. + * @param currentModeInstructions The current agent inherited when no subagent is requested. + * Its configured model keeps precedence over the Auto default. * @throws Error if the requested model is not found or exceeds the main model's cost tier. */ - private resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined, explicitModelQualifiedName?: string): { modeModelId: string | undefined; resolvedModelName: string | undefined } { + private async resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined, explicitModelQualifiedName?: string, currentModeInstructions?: IChatRequestModeInstructions): Promise { let modeModelId = mainModelId; let explicitModelResolved = false; + let usesAutoDefault = false; + let selectionSource: SubagentModelSelectionSource = 'mainModel'; + const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; // Explicit model parameter takes highest priority if (explicitModelQualifiedName) { @@ -508,6 +540,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { if (lm?.identifier) { modeModelId = lm.identifier; explicitModelResolved = true; + selectionSource = 'explicitModel'; } else { // Model not found - throw error with available models throw new Error(`Requested model '${explicitModelQualifiedName}' not found. ${this.getAvailableModelsInfo(mainModelId)}`); @@ -520,7 +553,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // When the main model is BYOK (flagged via `metadata.isBYOK`), skip Copilot/CAPI fallback models // for built-in agents (e.g. Explore), whose model list is a curated convenience fallback. A // user-authored agent's model list is a deliberate choice and is always honored as-is. - const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; const mainModelIsByok = !!mainModelMetadata && isByokModel(mainModelMetadata); const skipCopilotFallbacks = mainModelIsByok && isBuiltinAgent(subagent.source, subagent.uri, this.productService); // Find the actual model identifier from the qualified name(s) @@ -531,14 +563,30 @@ export class RunSubagentTool extends Disposable implements IToolImpl { continue; } modeModelId = lmByQualifiedName.identifier; + selectionSource = 'agentModel'; break; } } } } + if ( + !explicitModelResolved + && !subagent?.model?.length + && (!mainModelId || (!!mainModelMetadata && !isByokModel(mainModelMetadata))) + && this.configurationService.getValue(ChatConfiguration.SubagentsDefaultToAuto) === true + && !(await this.inheritedAgentHasModel(subagent, currentModeInstructions)) + ) { + const autoModelId = await this.resolveAutoModelId(); + if (autoModelId) { + modeModelId = autoModelId; + usesAutoDefault = true; + selectionSource = 'autoDefault'; + } + } + // Check multiplier constraint - throw error if requested model exceeds main model's cost tier - if (modeModelId) { + if (modeModelId && !usesAutoDefault) { const check = this.checkMultiplierConstraint(modeModelId, mainModelId); if (check.exceeds) { const modelMetadata = this.languageModelsService.lookupLanguageModel(modeModelId); @@ -547,7 +595,51 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; - return { modeModelId, resolvedModelName: resolvedModelMetadata?.name }; + return { modeModelId, resolvedModelName: resolvedModelMetadata?.name, selectionSource }; + } + + private async inheritedAgentHasModel(subagent: ICustomAgent | undefined, currentModeInstructions: IChatRequestModeInstructions | undefined): Promise { + if (subagent || !currentModeInstructions) { + return false; + } + const { uri, name } = currentModeInstructions; + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + const currentAgent = agents.find(agent => uri ? isEqual(agent.uri, uri) : agent.name === name && agent.enabled); + return !!currentAgent?.model?.length; + } + + private isSelectableForAgentMode(metadata: ILanguageModelChatMetadata): boolean { + return ILanguageModelChatMetadata.suitableForAgentMode(metadata) + && metadata.isUserSelectable !== false + && !metadata.targetChatSessionType; + } + + private findEligibleAutoModelId(modelIds: readonly string[]): string | undefined { + return modelIds.find(modelId => { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + return metadata?.vendor === COPILOT_VENDOR_ID + && metadata.id === AUTO_RAW_MODEL_ID + && this.isSelectableForAgentMode(metadata); + }); + } + + private resolveAutoModelId(): Promise { + const cachedModelId = this.findEligibleAutoModelId(this.languageModelsService.getLanguageModelIds()); + if (cachedModelId || this.languageModelsService.hasResolvedVendor(COPILOT_VENDOR_ID)) { + return Promise.resolve(cachedModelId); + } + this._autoModelResolution ??= this.activateAutoModel(); + return this._autoModelResolution; + } + + private async activateAutoModel(): Promise { + try { + const modelIds = await this.languageModelsService.selectLanguageModels({ vendor: COPILOT_VENDOR_ID, id: AUTO_RAW_MODEL_ID }); + return this.findEligibleAutoModelId(modelIds); + } catch (error) { + this.logService.warn('RunSubagentTool: Failed to resolve the Auto model, keeping the main model', error); + return undefined; + } } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -561,7 +653,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const subagent = requestedAgentName ? await this.getSubAgentByName(requestedAgentName) : undefined; // Resolve the model early and cache it for invoke() - const resolved = this.resolveSubagentModel(subagent, context.modelId, args.model); + const resolved = await this.resolveSubagentModel(subagent, context.modelId, args.model, currentModeInstructions); this._resolvedModels.set(context.toolCallId, resolved); return { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 63f77bf4783a97..461d1f2f0eabf2 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../../base/common/event.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../../platform/log/common/log.js'; @@ -13,9 +14,11 @@ import { RUN_SUBAGENT_MAX_NESTING_DEPTH, RunSubagentTool } from '../../../../com import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService, UserSelectedTools } from '../../../../common/participants/chatAgents.js'; import { IChatProgress, IChatService } from '../../../../common/chatService/chatService.js'; -import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; @@ -24,9 +27,178 @@ import { IToolInvocation, ToolProgress } from '../../../../common/tools/language import { IChatModel, IChatRequestModeInstructions } from '../../../../common/model/chatModel.js'; import { ChatConfiguration } from '../../../../common/constants.js'; +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } +} + suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + function createMetadata(name: string, multiplierNumeric?: number, vendor: string = 'TestVendor'): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.extension'), + name, + id: name.toLowerCase().replace(/\s+/g, '-'), + vendor, + version: '1.0', + family: 'test', + maxInputTokens: 128000, + maxOutputTokens: 8192, + isDefaultForLocation: {}, + multiplierNumeric, + capabilities: { toolCalling: true }, + isBYOK: vendor !== COPILOT_VENDOR_ID, + }; + } + + function createAutoMetadata(multiplierNumeric?: number, overrides: Partial = {}): ILanguageModelChatMetadata { + return { + ...createMetadata('Auto', multiplierNumeric, COPILOT_VENDOR_ID), + id: AUTO_RAW_MODEL_ID, + ...overrides, + }; + } + + function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { + const id = `file:///test/${name}.md`; + return { + uri: URI.parse(id), + id, + name, + description: `Agent ${name}`, + tools: ['tool1'], + model: modelQualifiedNames, + agentInstructions: { content: 'test', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.Undefined, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true + }; + } + + function createLanguageModelsServiceMock(models = new Map(), opts: { + qualifiedNameMap?: Map; + selectedModels?: Map; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; + } = {}): ILanguageModelsService { + const service: Partial = { + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => Array.from(models.keys()), + lookupLanguageModel: modelId => models.get(modelId), + lookupLanguageModelByQualifiedName: qualifiedName => opts.qualifiedNameMap?.get(qualifiedName), + hasResolvedVendor: vendor => { + assert.strictEqual(vendor, COPILOT_VENDOR_ID); + return opts.copilotVendorResolved ?? false; + }, + selectLanguageModels: async selector => { + opts.onSelectLanguageModels?.(); + assert.deepStrictEqual(selector, { vendor: COPILOT_VENDOR_ID, id: AUTO_RAW_MODEL_ID }); + for (const [modelId, metadata] of opts.selectedModels ?? []) { + models.set(modelId, metadata); + } + return Array.from(models) + .filter(([, metadata]) => metadata.vendor === selector.vendor && metadata.id === selector.id) + .map(([modelId]) => modelId); + }, + getModelConfiguration: () => undefined, + }; + return service as ILanguageModelsService; + } + + let callIdCounter = 0; + function createInvokableTool(opts: { + allowInvocationsFromSubagents: boolean; + capturedRequests: IChatAgentRequest[]; + currentModeInstructions?: IChatRequestModeInstructions; + customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; + models?: Map; + selectedModels?: Map; + qualifiedNameMap?: Map; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; + telemetryService?: ITelemetryService; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: opts.allowInvocationsFromSubagents, + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }); + const promptsService = new MockPromptsService(); + if (opts.customAgents) { + promptsService.setCustomModes(opts.customAgents); + } + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ + id: 'req-1', + modeInfo: opts.currentModeInstructions ? { + kind: undefined, + isBuiltin: false, + modeInstructions: opts.currentModeInstructions, + telemetryModeId: 'custom', + applyCodeBlockSuggestionId: undefined, + } : undefined + }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + const tool = testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + createLanguageModelsServiceMock(opts.models, opts), + new NullLogService(), + configService, + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + opts.telemetryService ?? NullTelemetryService, + )); + + return { tool, mockChatAgentService }; + } + + function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools, modelId?: string): IToolInvocation { + return { + callId: `call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test' }, + context: { sessionResource: sessionUri }, + modelId, + userSelectedTools: userSelectedTools ?? { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + suite('resultText trimming', () => { test('trims leading empty codeblocks (```\\n```) from result', () => { // This tests the regex: /^\n*```\n+```\n*/g @@ -70,12 +242,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); const result = await tool.prepareToolInvocation( @@ -113,12 +286,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return tool; } @@ -155,12 +329,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); const toolData = tool.getToolData(); @@ -242,27 +417,14 @@ suite('RunSubagentTool', () => { const BUILTIN_CHAT_EXTENSION_ID = 'github.copilot-chat'; const builtinProductService = { defaultChatAgent: { chatExtensionId: BUILTIN_CHAT_EXTENSION_ID } } as IProductService; - function createMetadata(name: string, multiplierNumeric?: number, vendor: string = 'TestVendor'): ILanguageModelChatMetadata { - return { - extension: new ExtensionIdentifier('test.extension'), - name, - id: name.toLowerCase().replace(/\s+/g, '-'), - vendor, - version: '1.0', - family: 'test', - maxInputTokens: 128000, - maxOutputTokens: 8192, - isDefaultForLocation: {}, - multiplierNumeric, - capabilities: { toolCalling: true }, - isBYOK: vendor !== COPILOT_VENDOR_ID, - }; - } - function createTool(opts: { models: Map; + selectedModels?: Map; qualifiedNameMap?: Map; customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; }) { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const promptsService = new MockPromptsService(); @@ -270,50 +432,24 @@ suite('RunSubagentTool', () => { promptsService.setCustomModes(opts.customAgents); } - const mockLanguageModelsService: Partial = { - getLanguageModelIds() { - return Array.from(opts.models.keys()); - }, - lookupLanguageModel(modelId: string) { - return opts.models.get(modelId); - }, - lookupLanguageModelByQualifiedName(qualifiedName: string) { - return opts.qualifiedNameMap?.get(qualifiedName); - }, - }; - const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, mockToolsService, - mockLanguageModelsService as ILanguageModelsService, + createLanguageModelsServiceMock(opts.models, opts), new NullLogService(), - new TestConfigurationService(), + new TestConfigurationService({ + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }), promptsService, {} as IInstantiationService, builtinProductService, + NullTelemetryService, )); return tool; } - function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { - const id = `file:///test/${name}.md`; - return { - uri: URI.parse(id), - id, - name, - description: `Agent ${name}`, - tools: ['tool1'], - model: modelQualifiedNames, - agentInstructions: { content: 'test', toolReferences: [] }, - source: { storage: PromptsStorage.local }, - target: Target.Undefined, - visibility: { userInvocable: true, agentInvocable: true }, - enabled: true - }; - } - // A built-in (extension-shipped) agent such as Explore, whose model list is a curated fallback list. function createBuiltinAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { return { @@ -354,18 +490,20 @@ suite('RunSubagentTool', () => { }); test('uses subagent model when it has equal multiplier', async () => { - const mainMeta = createMetadata('GPT-4o', 1); + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); const sameCostMeta = createMetadata('Claude Sonnet', 1); + const autoMeta = createAutoMetadata(); const models = new Map([ ['main-model-id', mainMeta], ['same-cost-model-id', sameCostMeta], + ['copilot-auto-model-id', autoMeta], ]); const qualifiedNameMap = new Map([ ['Claude Sonnet (TestVendor)', { metadata: sameCostMeta, identifier: 'same-cost-model-id' }], ]); const agent = createAgent('SameCostAgent', ['Claude Sonnet (TestVendor)']); - const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent], defaultToAuto: true }); const result = await tool.prepareToolInvocation({ parameters: { prompt: 'test', description: 'test task', agentName: 'SameCostAgent' }, @@ -526,6 +664,257 @@ suite('RunSubagentTool', () => { }); }); + test('resolves and uses Auto from a cold provider when enabled and no subagent is specified', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + defaultToAuto: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'auto-call-1', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'Auto', + selectCalls: 1, + }); + }); + + test('uses Auto when enabled and subagent has no model configured', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const agent = createAgent('NoModelAgent', undefined); + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + customAgents: [agent], + defaultToAuto: true, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'NoModelAgent' }, + toolCallId: 'auto-call-2', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'Auto'); + }); + + test('falls back to main model when Auto is unavailable after provider resolution', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'auto-call-3', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'GPT-4o', + selectCalls: 0, + }); + }); + + test('falls back to main model when cached Auto is ineligible', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const ineligibleAutoModels = [ + createAutoMetadata(undefined, { capabilities: { toolCalling: false } }), + createAutoMetadata(undefined, { isUserSelectable: false }), + createAutoMetadata(undefined, { targetChatSessionType: 'other-session' }), + ]; + + for (const [index, autoMeta] of ineligibleAutoModels.entries()) { + let selectCalls = 0; + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + [`copilot-auto-model-id-${index}`, autoMeta], + ]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: `ineligible-auto-call-${index}`, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'GPT-4o', + selectCalls: 0, + }); + } + }); + + test('keeps main model and does not retry when Auto resolution fails', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + defaultToAuto: true, + onSelectLanguageModels: () => { + selectCalls++; + throw new Error('activation failed'); + }, + }); + + const modelNames: (string | undefined)[] = []; + for (const toolCallId of ['failed-auto-1', 'failed-auto-2']) { + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + modelNames.push(result?.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined); + } + + assert.deepStrictEqual({ modelNames, selectCalls }, { modelNames: ['GPT-4o', 'GPT-4o'], selectCalls: 1 }); + }); + + test('keeps main model when its metadata is unknown', async () => { + let selectCalls = 0; + const tool = createTool({ + models: new Map([['copilot-auto-model-id', createAutoMetadata()]]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'unknown-main-model', + modelId: 'unknown-main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.deepStrictEqual({ + modelName: result?.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: undefined, + selectCalls: 0, + }); + }); + + test('keeps BYOK main model without resolving Copilot Auto', async () => { + const byokMain = createMetadata('Claude Sonnet BYOK', undefined, 'anthropic'); + const autoMeta = createAutoMetadata(); + + for (const agent of [undefined, createAgent('NoModelAgent', undefined)]) { + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-byok-id', byokMain]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + customAgents: agent ? [agent] : undefined, + defaultToAuto: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: agent?.name }, + toolCallId: `byok-auto-call-${agent?.name ?? 'unnamed'}`, + modelId: 'main-byok-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'Claude Sonnet BYOK', + selectCalls: 0, + }); + } + }); + + test('reuses warm Auto without provider refresh and exempts it from fixed multiplier constraint', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(50); + let selectCalls = 0; + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + for (const toolCallId of ['warm-auto-1', 'warm-auto-2']) { + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'Auto'); + } + assert.strictEqual(selectCalls, 0); + }); + + test('keeps main model when configured subagent model is unavailable', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const unavailableAgent = createAgent('UnavailableAgent', ['Missing Model (TestVendor)']); + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + customAgents: [unavailableAgent], + defaultToAuto: true, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'UnavailableAgent' }, + toolCallId: 'unavailable-agent-model', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'GPT-4o'); + }); + test('skips Copilot fallback models when main model is BYOK and inherits the main model', async () => { const mainMeta = createMetadata('Claude Sonnet BYOK', undefined, 'anthropic'); const copilotFallback = createMetadata('Copilot Haiku', undefined, COPILOT_VENDOR_ID); @@ -687,26 +1076,11 @@ suite('RunSubagentTool', () => { }); suite('explicit model parameter', () => { - function createMetadata(name: string, multiplierNumeric?: number): ILanguageModelChatMetadata { - return { - extension: new ExtensionIdentifier('test.extension'), - name, - id: name.toLowerCase().replace(/\s+/g, '-'), - vendor: 'TestVendor', - version: '1.0', - family: 'test', - maxInputTokens: 128000, - maxOutputTokens: 8192, - isDefaultForLocation: {}, - multiplierNumeric, - capabilities: { toolCalling: true }, - }; - } - function createTool(opts: { models: Map; qualifiedNameMap?: Map; customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; }) { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const promptsService = new MockPromptsService(); @@ -714,50 +1088,24 @@ suite('RunSubagentTool', () => { promptsService.setCustomModes(opts.customAgents); } - const mockLanguageModelsService: Partial = { - getLanguageModelIds() { - return Array.from(opts.models.keys()); - }, - lookupLanguageModel(modelId: string) { - return opts.models.get(modelId); - }, - lookupLanguageModelByQualifiedName(qualifiedName: string) { - return opts.qualifiedNameMap?.get(qualifiedName); - }, - }; - const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, mockToolsService, - mockLanguageModelsService as ILanguageModelsService, + createLanguageModelsServiceMock(opts.models, opts), new NullLogService(), - new TestConfigurationService(), + new TestConfigurationService({ + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return tool; } - function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { - const id = `file:///test/${name}.md`; - return { - id, - uri: URI.parse(id), - name, - description: `Agent ${name}`, - tools: ['tool1'], - model: modelQualifiedNames, - agentInstructions: { content: 'test', toolReferences: [] }, - source: { storage: PromptsStorage.local }, - target: Target.Undefined, - visibility: { userInvocable: true, agentInvocable: true }, - enabled: true - }; - } - test('model property is included in tool schema without enum', () => { const models = new Map([ ['model-1', createMetadata('GPT-4o')], @@ -774,7 +1122,7 @@ suite('RunSubagentTool', () => { }); test('resolves explicit model parameter without agentName', async () => { - const mainMeta = createMetadata('GPT-4o', 1); + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); const explicitMeta = createMetadata('Claude Sonnet', 1); const models = new Map([ ['main-model-id', mainMeta], @@ -784,7 +1132,7 @@ suite('RunSubagentTool', () => { ['Claude Sonnet (TestVendor)', { metadata: explicitMeta, identifier: 'explicit-model-id' }], ]); - const tool = createTool({ models, qualifiedNameMap }); + const tool = createTool({ models, qualifiedNameMap, defaultToAuto: true }); const result = await tool.prepareToolInvocation({ parameters: { prompt: 'test', description: 'test task', model: 'Claude Sonnet (TestVendor)' }, @@ -981,12 +1329,13 @@ suite('RunSubagentTool', () => { mockChatAgentService as IChatAgentService, mockChatService as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, mockInstantiationService as IInstantiationService, {} as IProductService, + NullTelemetryService, )); } @@ -1064,84 +1413,6 @@ suite('RunSubagentTool', () => { }); suite('nested subagent depth tracking', () => { - /** - * Creates a RunSubagentTool with mocked services suitable for invoke() testing. - * The returned `capturedRequests` array collects every IChatAgentRequest passed to invokeAgent. - */ - let callIdCounter = 0; - function createInvokableTool(opts: { - allowInvocationsFromSubagents: boolean; - capturedRequests: IChatAgentRequest[]; - currentModeInstructions?: IChatRequestModeInstructions; - }) { - const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService({ - [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: opts.allowInvocationsFromSubagents, - }); - const promptsService = new MockPromptsService(); - - const mockChatAgentService: Pick = { - getDefaultAgent() { - return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; - }, - async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { - opts.capturedRequests.push(request); - return {}; - }, - }; - - const mockChatService: Pick = { - getSession() { - return { - getRequests: () => [{ - id: 'req-1', - modeInfo: opts.currentModeInstructions ? { - kind: undefined, - isBuiltin: false, - modeInstructions: opts.currentModeInstructions, - telemetryModeId: 'custom', - applyCodeBlockSuggestionId: undefined, - } : undefined - }], - acceptResponseProgress: () => { }, - } as unknown as IChatModel; - }, - }; - - const mockInstantiationService: Pick = { - createInstance(..._args: never[]): { collect: () => Promise } { - return { collect: async () => { } }; - }, - }; - - const tool = testDisposables.add(new RunSubagentTool( - mockChatAgentService as IChatAgentService, - mockChatService as IChatService, - mockToolsService, - {} as ILanguageModelsService, - new NullLogService(), - configService, - promptsService, - mockInstantiationService as IInstantiationService, - {} as IProductService, - )); - - return { tool, mockChatAgentService }; - } - - function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools): IToolInvocation { - return { - callId: `call-${++callIdCounter}`, - toolId: 'runSubagent', - parameters: { prompt: 'do something', description: 'test' }, - context: { sessionResource: sessionUri }, - userSelectedTools: userSelectedTools ?? { runSubagent: true }, - } as IToolInvocation; - } - - const countTokens = async () => 0; - const noProgress: ToolProgress = { report() { } }; - test('disables runSubagent tool when nesting is disabled', async () => { const capturedRequests: IChatAgentRequest[] = []; const { tool } = createInvokableTool({ allowInvocationsFromSubagents: false, capturedRequests }); @@ -1225,6 +1496,186 @@ suite('RunSubagentTool', () => { }); }); + suite('default to Auto model', () => { + test('passes prepared Auto model to participant without resolving it again', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + const sessionUri = URI.parse('test://session/prepared-auto'); + const invocation = createInvocation(sessionUri, undefined, 'main-model-id'); + + await tool.prepareToolInvocation({ + parameters: invocation.parameters, + toolCallId: invocation.callId, + modelId: invocation.modelId, + chatSessionResource: sessionUri, + }, CancellationToken.None); + assert.deepStrictEqual(telemetryService.events, []); + await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + userSelectedModelId: 'copilot-auto-model-id', + selectCalls: 1, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'autoDefault' }, + }], + }); + }); + + test('resolves and passes Auto to participant when preparation was skipped', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + + await tool.invoke( + createInvocation(URI.parse('test://session/direct-auto'), undefined, 'main-model-id'), + countTokens, + noProgress, + CancellationToken.None, + ); + + assert.deepStrictEqual({ + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + userSelectedModelId: 'copilot-auto-model-id', + selectCalls: 1, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'autoDefault' }, + }], + }); + }); + + test('keeps main model when the inherited current agent configures a model', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const currentAgent = createAgent('CurrentAgent', ['Claude Sonnet (TestVendor)']); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + currentModeInstructions: { uri: currentAgent.uri, name: currentAgent.name, content: 'test', toolReferences: [] }, + customAgents: [currentAgent], + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + const sessionUri = URI.parse('test://session/inherited-agent-model'); + const invocation = createInvocation(sessionUri, undefined, 'main-model-id'); + + await tool.prepareToolInvocation({ + parameters: invocation.parameters, + toolCallId: invocation.callId, + modelId: invocation.modelId, + chatSessionResource: sessionUri, + }, CancellationToken.None); + await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + subAgentName: capturedRequests[0].subAgentName, + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + subAgentName: 'CurrentAgent', + userSelectedModelId: 'main-model-id', + selectCalls: 0, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'mainModel' }, + }], + }); + }); + + test('reports explicit and configured agent model selection sources', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const selectedMeta = createMetadata('Claude Sonnet', 1); + const qualifiedName = 'Claude Sonnet (TestVendor)'; + const qualifiedNameMap = new Map([ + [qualifiedName, { metadata: selectedMeta, identifier: 'selected-model-id' }], + ]); + const configuredAgent = { ...createAgent('ConfiguredAgent', [qualifiedName]), tools: undefined }; + const selections: unknown[] = []; + + for (const testCase of [ + { name: 'explicit', parameters: { prompt: 'do something', description: 'test', model: qualifiedName } }, + { name: 'agent', parameters: { prompt: 'do something', description: 'test', agentName: 'ConfiguredAgent' } }, + ]) { + const telemetryService = new TestTelemetryService(); + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + customAgents: [configuredAgent], + defaultToAuto: true, + models: new Map([ + ['main-model-id', mainMeta], + ['selected-model-id', selectedMeta], + ['copilot-auto-model-id', createAutoMetadata()], + ]), + qualifiedNameMap, + copilotVendorResolved: true, + telemetryService, + }); + const invocation = createInvocation(URI.parse(`test://session/${testCase.name}`), undefined, 'main-model-id'); + invocation.parameters = testCase.parameters; + + const result = await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + if (capturedRequests.length !== 1) { + throw new Error(`${testCase.name}: ${JSON.stringify(result)}`); + } + selections.push({ + selectedModelId: capturedRequests[0].userSelectedModelId, + telemetry: telemetryService.events[0], + }); + } + + assert.deepStrictEqual(selections, [ + { + selectedModelId: 'selected-model-id', + telemetry: { name: 'chat.subagentModelSelection', data: { selectionSource: 'explicitModel' } }, + }, + { + selectedModelId: 'selected-model-id', + telemetry: { name: 'chat.subagentModelSelection', data: { selectionSource: 'agentModel' } }, + }, + ]); + }); + }); + suite('subagent credits', () => { let creditsCallIdCounter = 0; @@ -1273,12 +1724,13 @@ suite('RunSubagentTool', () => { mockChatAgentService as IChatAgentService, mockChatService as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), configService, promptsService, mockInstantiationService as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return { tool, parentCredits }; } From 5be7ea096209c6b2b12a2d9abb0ada73767d52a9 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:19:39 -0700 Subject: [PATCH 31/44] policy: disable custom terminal tool for managed accounts (#334297) * policy: disable custom terminal tool under managed settings Force the Agent Host custom terminal tool setting off whenever any Copilot managed-settings channel is active, while preserving the user setting for ungoverned accounts. Export the policy catalog and cover the shared policy callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: enforce terminal override for raw managed settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/policies/policyData.jsonc | 15 +++ src/vs/base/common/defaultAccount.ts | 4 +- .../policy/common/copilotManagedSettings.ts | 14 ++- .../common/copilotManagedSettings.test.ts | 16 ++- .../chat/browser/chat.shared.contribution.ts | 14 ++- .../accounts/browser/defaultAccount.ts | 2 +- .../accounts/browser/managedSettings.ts | 11 +- .../test/browser/managedSettings.test.ts | 12 +- .../policies/common/accountPolicyService.ts | 30 +++-- .../test/browser/accountPolicyService.test.ts | 107 ++++++++++++++---- 10 files changed, 183 insertions(+), 42 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index a61a6525255bb5..a5ac97ef8bd633 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -220,6 +220,21 @@ "default": false, "included": true }, + { + "key": "chat.agentHost.customTerminalTool.enabled", + "name": "ChatAgentHostCustomTerminalTool", + "category": "InteractiveSession", + "minimumVersion": "1.137", + "localization": { + "description": { + "key": "chat.agentHost.customTerminalTool.enabled.policy", + "value": "Enable the Agent Host custom terminal tool override for Copilot SDK sessions." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agentHost.otel.captureContent", "name": "CopilotOtelCaptureContent", diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index bd01fbb3005432..17dcc8bf9a14c6 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -76,8 +76,8 @@ export interface IPolicyData { * the user is governed by GitHub Copilot managed settings at all, independent of which keys * were set. * - * Unlike {@link managedSettings}, this is not projected onto the keys VS Code declares, so it - * also reflects runtime-owned keys VS Code never reads. + * Unlike {@link managedSettings}, this also reflects structured runtime-owned keys that the + * policy projection does not retain. */ readonly managedSettingsActive?: boolean; } diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 8f2d250245410e..8fe945cbcda2c4 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -16,6 +16,11 @@ export type { ManagedSettingsData } from '../../../base/common/policy.js'; export type RawManagedSettingsData = Readonly>; +/** Whether a raw managed-settings document contains at least one top-level setting. */ +export function hasRawManagedSettings(data: RawManagedSettingsData | undefined): boolean { + return data !== undefined && Object.keys(data).length > 0; +} + /** Windows registry root for GitHub Copilot policies. */ export const GITHUB_COPILOT_WIN32_REGISTRY_PATH = 'SOFTWARE\\Policies\\GitHubCopilot'; @@ -213,6 +218,11 @@ export function managedModelValue(): (policyData: IPolicyData) => ManagedSetting return managedModelValueCallback; } +/** Forces a boolean setting off while the user is governed by managed settings. */ +export function managedSettingsDisabledValue(policyData: IPolicyData): boolean | undefined { + return policyData.managedSettingsActive === true ? false : undefined; +} + /** * `value` callback shared by the third-party agent harness policies (`Claude3PIntegration`, * `Codex3PIntegration`): forces the harness off when the account disables chat preview features, @@ -223,9 +233,7 @@ export function managedModelValue(): (policyData: IPolicyData) => ManagedSetting * every managed control the enterprise set. */ export function thirdPartyAgentEnabledValue(policyData: IPolicyData): boolean | undefined { - return policyData.chat_preview_features_enabled === false || policyData.managedSettingsActive === true - ? false - : undefined; + return policyData.chat_preview_features_enabled === false ? false : managedSettingsDisabledValue(policyData); } export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index a88d7edae7c0b0..1c509b8f0671bc 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -106,6 +106,20 @@ suite('Copilot managed settings projection', () => { assert.strictEqual(managedModelValue(), managedModelValue()); }); + test('managedSettingsDisabledValue forces false only while managed settings are active', () => { + assert.deepStrictEqual({ + active: managedSettingsDisabledValue({ managedSettingsActive: true }), + inactive: managedSettingsDisabledValue({ managedSettingsActive: false }), + unset: managedSettingsDisabledValue({}), + previewFeaturesDisabled: managedSettingsDisabledValue({ chat_preview_features_enabled: false }), + }, { + active: false, + inactive: undefined, + unset: undefined, + previewFeaturesDisabled: undefined, + }); + }); + test('forceRemoteSettingsRefresh resolves across all channels and reports the winning source', () => { const key = COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY; assert.deepStrictEqual({ diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index e0e7c0fd2411cd..fb3af23807fddf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -37,7 +37,7 @@ import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL } from '../../../../platform/localTra import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAllowedServersConfig, mcpAppsEnabledConfig, mcpAutoStartConfig, mcpDeniedServersConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; -import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, managedSettingsDisabledValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; @@ -1625,6 +1625,18 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentHost.customTerminalTool.enabled', "When enabled, Copilot SDK sessions use the Agent Host terminal tool override instead of the SDK's default terminal behavior."), default: false, tags: ['experimental', 'advanced'], + policy: { + name: 'ChatAgentHostCustomTerminalTool', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.137', + value: managedSettingsDisabledValue, + localization: { + description: { + key: 'chat.agentHost.customTerminalTool.enabled.policy', + value: nls.localize('chat.agentHost.customTerminalTool.enabled.policy', "Enable the Agent Host custom terminal tool override for Copilot SDK sessions."), + } + } + }, }, [AgentHostShellToolInitScriptEnabledSettingId]: { type: 'boolean', diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 23f0dbb85c1ae2..ed179795202258 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -1337,7 +1337,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const adapted = adaptManagedSettings(data ?? {}, msg => this.logService.warn(msg)); // An empty response (`{}`) is a successful "no policy file present" signal. const managedSettingsCount = adapted.managedSettings ? Object.keys(adapted.managedSettings).length : 0; - if (managedSettingsCount === 0) { + if (managedSettingsCount === 0 && adapted.managedSettingsActive !== true) { this.logService.debug('[DefaultAccount] Managed settings fetched (empty response — no enterprise policy file present)'); } else { this.logService.info('[DefaultAccount] Managed settings applied'); diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index 4fc1370469e7dd..b764f929f499db 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -7,7 +7,7 @@ import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { IProductConfiguration } from '../../../../base/common/product.js'; import { isString } from '../../../../base/common/types.js'; import { IManagedSettingsCompatibilityError, MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE } from '../../../../platform/defaultAccount/common/defaultAccount.js'; -import { normalizeManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { hasRawManagedSettings, normalizeManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; /** * Client identity VS Code reports to the managed settings service. It names this codebase's own @@ -40,6 +40,9 @@ export type IManagedMcpServerMatcher = export interface IManagedSettingsResponse { readonly permissions?: { readonly disableBypassPermissionsMode?: string; + readonly allow?: readonly string[]; + readonly ask?: readonly string[]; + readonly deny?: readonly string[]; /** * Legacy location for the default chat model. Retained for deployments authored against * the original schema; the top-level {@link IManagedSettingsResponse.model} wins when both @@ -144,5 +147,9 @@ export function parseManagedSettingsCompatibilityError(response: unknown): IMana * Exported for unit-testing the shape transformation independently of network I/O. */ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn?: (msg: string) => void): Partial { - return { managedSettings: normalizeManagedSettings(response as Record, onWarn) }; + const managedSettings = normalizeManagedSettings(response as Record, onWarn); + return { + managedSettings, + ...(Object.keys(managedSettings).length === 0 && hasRawManagedSettings(response) ? { managedSettingsActive: true } : {}), + }; } diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 005ff029267d9a..477b4f9426d303 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -46,6 +46,15 @@ suite('adaptManagedSettings', () => { }); }); + test('marks permission rules as active when they are retained only in the raw response', () => { + assert.deepStrictEqual(adaptManagedSettings({ + permissions: { deny: ['Shell'] }, + }), { + managedSettings: {}, + managedSettingsActive: true, + }); + }); + test('parses the stable compatibility error and optional versions', () => { assert.deepStrictEqual(parseManagedSettingsCompatibilityError({ error_code: 'client_update_required', @@ -281,7 +290,7 @@ suite('adaptManagedSettings', () => { } as IManagedSettingsResponse, msg => warnings.push(msg)); assert.deepStrictEqual( { result, warned: warnings.length, mentionsRepo: warnings.some(w => w.includes('requires "repo"')) }, - { result: { managedSettings: {} }, warned: 1, mentionsRepo: true } + { result: { managedSettings: {}, managedSettingsActive: true }, warned: 1, mentionsRepo: true } ); }); @@ -290,6 +299,7 @@ suite('adaptManagedSettings', () => { extraKnownMarketplaces: ['https://plugins.acme.com'] as unknown as IManagedSettingsResponse['extraKnownMarketplaces'], } as IManagedSettingsResponse), { managedSettings: {}, + managedSettingsActive: true, }); }); diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index e377d53d0af816..5ed01ef0800763 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -12,7 +12,7 @@ import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, MANAGED_SETTINGS_CHANNELS, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, hasRawManagedSettings, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingsFreshness, isManagedSettingsFreshnessBlocking } from '../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -67,6 +67,7 @@ export interface IAccountPolicyGateService { interface IResolvedPolicyData { readonly policyData: IPolicyData; readonly managedSettingResolutions: IManagedSettingsPick['resolutions']; + readonly activeManagedSettingsSources: readonly ManagedSettingsChannel[]; } export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService, IManagedSettingsService { @@ -128,7 +129,7 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli })); } if (this.fileManagedSettingsService) { - this._register(this.fileManagedSettingsService.onDidChangeManagedSettings(() => { + this._register(this.fileManagedSettingsService.onDidChangeRawManagedSettings(() => { this._updatePolicyDefinitions(this.policyDefinitions); })); } @@ -186,7 +187,7 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli return undefined; } - const { policyData, managedSettingResolutions } = resolvedPolicyData; + const { policyData, managedSettingResolutions, activeManagedSettingsSources } = resolvedPolicyData; const value = valueProvider(policyData); if (value === undefined) { return undefined; @@ -231,13 +232,9 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli // declared key, so probe for that too and attribute it to the governing channels. if (source === PolicyValueSource.Account && policyData.managedSettingsActive === true && valueProvider({ ...policyData, managedSettingsActive: false }) !== value) { - const channels = new Set(); - for (const resolution of managedSettingResolutions.values()) { - channels.add(resolution.source); - } - if (channels.size > 0) { - source = channels.size === 1 - ? policyValueSourceForManagedSettingsChannel(Array.from(channels)[0]) + if (activeManagedSettingsSources.length > 0) { + source = activeManagedSettingsSources.length === 1 + ? policyValueSourceForManagedSettingsChannel(activeManagedSettingsSources[0]) : PolicyValueSource.MixedManagedSettings; } } @@ -263,11 +260,19 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli // channel is still filled in by a lower one. A key locked by a higher channel cannot be // overwritten. See `.github/skills/policy-and-managed-settings/github-managed-settings.md` for the rationale. const pick = pickManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings); + const activeSources = new Set(pick.activeSources); + if (accountPolicyData?.managedSettingsActive === true) { + activeSources.add('server'); + } + if (hasRawManagedSettings(this.fileManagedSettingsService?.rawManagedSettings)) { + activeSources.add('file'); + } + const activeManagedSettingsSources = MANAGED_SETTINGS_CHANNELS.filter(source => activeSources.has(source)); if (!equals(this._managedSettings, pick.values)) { this._managedSettings = pick.values; this._onDidChangeManagedSettings.fire(); } - if (!accountPolicyData && pick.activeSources.length === 0) { + if (!accountPolicyData && activeManagedSettingsSources.length === 0) { return undefined; } @@ -282,9 +287,10 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli policyData: { ...accountPolicyData, managedSettings: managedSettingsData, - managedSettingsActive: pick.activeSources.length > 0, + managedSettingsActive: activeManagedSettingsSources.length > 0, }, managedSettingResolutions: pick.resolutions, + activeManagedSettingsSources, }; } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index b11c95a9f4e6ea..6717ba9e6f21cb 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ManagedSettingsData, PolicyCategory } from '../../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; import { IDefaultAccountProvider, IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, thirdPartyAgentEnabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, RawManagedSettingsData, managedSettingsDisabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -198,9 +199,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - // Mirrors the third-party harness policies: keys off the presence of managed - // settings, so it deliberately declares no `managedSettings`. - value: thirdPartyAgentEnabledValue, + value: managedSettingsDisabledValue, } } } @@ -412,7 +411,7 @@ suite('AccountPolicyService', () => { // All three channels provide the same key with different values. // Server says 'enable', MDM says 'disable', File says 'file-value'. // Native MDM should win. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value', [COPILOT_SANDBOX_ENABLED_KEY]: true }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value', [COPILOT_SANDBOX_ENABLED_KEY]: true })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable', [COPILOT_SANDBOX_ENABLED_KEY]: false })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -445,7 +444,7 @@ suite('AccountPolicyService', () => { test('managed settings: file-based settings apply when server and MDM are empty', async () => { // Only the file channel provides a value — it should be used. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({})); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -467,7 +466,7 @@ suite('AccountPolicyService', () => { // key. Neither overrides the other, so BOTH reach policy evaluation: setting F resolves from // native MDM and setting G resolves from the file. This is the per-key fill-down behavior. const enabledPluginsJson = '{"assign-issue@skills":true}'; - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -494,7 +493,7 @@ suite('AccountPolicyService', () => { test('managed settings: attributes policies caused by multiple channels as mixed', async () => { const enabledPluginsJson = '{"assign-issue@skills":true}'; - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -514,9 +513,8 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: their mere presence disables the third-party harnesses', async () => { - // A runtime-owned key VS Code never declares still counts as governance. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ 'permissions.deny': '["Bash"]' }); + test('managed settings: raw permission rules disable presence-gated settings', async () => { + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({}, { permissions: { deny: ['Shell'] } })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, undefined, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); await defaultConfiguration.initialize(); @@ -537,7 +535,68 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: an ungoverned account leaves the third-party harnesses alone', async () => { + test('managed settings: removing raw permission rules releases presence-gated settings', async () => { + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({}, { permissions: { deny: ['Shell'] } })); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, undefined, fileManagedSettingsService)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + const change = Event.toPromise(policyService.onDidChange); + fileManagedSettingsService.setManagedSettings({}, {}); + assert.deepStrictEqual({ + changed: await change, + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + }, { + changed: ['PolicySettingJ'], + setting: undefined, + value: undefined, + }); + }); + + test('managed settings: sandbox settings disable presence-gated settings even when false', async () => { + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { + managedSettings: { [COPILOT_SANDBOX_ENABLED_KEY]: false }, + })); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.deepStrictEqual({ + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + source: policyService.getPolicyValueSource('PolicySettingJ'), + }, { + setting: false, + value: false, + source: PolicyValueSource.ServerManagedSettings, + }); + }); + + test('managed settings: raw server settings disable presence-gated settings', async () => { + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { + managedSettings: {}, + managedSettingsActive: true, + })); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.deepStrictEqual({ + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + source: policyService.getPolicyValueSource('PolicySettingJ'), + }, { + setting: false, + value: false, + source: PolicyValueSource.ServerManagedSettings, + }); + }); + + test('managed settings: an ungoverned account leaves presence-gated settings alone', async () => { defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { chat_preview_features_enabled: true })); await defaultAccountService.refresh(); await policyConfiguration.initialize(); @@ -669,16 +728,26 @@ suite('AccountPolicyService', () => { } } - class FakeFileManagedSettingsService implements IFileManagedSettingsService { + class FakeFileManagedSettingsService extends Disposable implements IFileManagedSettingsService { readonly _serviceBrand: undefined; - readonly rawManagedSettings = {}; - readonly onDidChangeRawManagedSettings = Event.None; - private readonly _onDidChangeManagedSettings = new Emitter(); - readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; - - constructor(public managedSettings: ManagedSettingsData = {}) { } + private readonly _onDidChangeRawManagedSettings = this._register(new Emitter()); + readonly onDidChangeRawManagedSettings = this._onDidChangeRawManagedSettings.event; + readonly onDidChangeManagedSettings = Event.None; + + constructor( + public managedSettings: ManagedSettingsData = {}, + public rawManagedSettings: RawManagedSettingsData = managedSettings, + ) { + super(); + } async initialize(): Promise { return this.managedSettings; } + + setManagedSettings(managedSettings: ManagedSettingsData, rawManagedSettings: RawManagedSettingsData = managedSettings): void { + this.managedSettings = managedSettings; + this.rawManagedSettings = rawManagedSettings; + this._onDidChangeRawManagedSettings.fire(this.rawManagedSettings); + } } async function setupGate(opts: { From 315dcc53a551775af41656892de0bfd4144bf5b4 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Thu, 3 Sep 2026 13:48:42 -0700 Subject: [PATCH 32/44] Avoid changing model picker for subagent Auto retries (#334357) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extension/conversation/vscode-node/chatParticipants.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts index 950ade9e2b320b..1b90e0ccbecb5f 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts @@ -325,8 +325,10 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (!autoModel) { return request; } - await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: autoModel.vendor, id: autoModel.id, family: autoModel.family }); request = { ...request, model: autoModel }; + if (request.subAgentInvocationId === undefined) { + await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: autoModel.vendor, id: autoModel.id, family: autoModel.family }); + } if (alwaysSwitchToAuto) { await vscode.workspace.getConfiguration('github.copilot').update('chat.rateLimitAutoSwitchToAuto', true, vscode.ConfigurationTarget.Global); } From 3fa0c2b627dd7e56fce350910e51e07dddb74ed1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 17:09:45 -0400 Subject: [PATCH 33/44] agentHost: remove redundant picker tab stop (#334306) * agentHost: remove redundant picker tab stop Keep picker action wrappers out of the tab order and delegate toolbar focus to the first real control inside the picker. Add regression coverage for the wrapper and inner-control focus behavior.\n\nFixes #333924\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: complete picker focus delegation Restore the BaseActionViewItem setFocusable signature, clear focused descendants on blur, and exercise nested focus traversal in the regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: update agent input screenshot hashes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: keep async picker wrapper out of tab order Keep the composite action wrapper non-focusable when its controls render after the toolbar initializes, and cover the delayed-render sequence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostSessionConfigPicker.ts | 37 +++++++++++++----- .../agentHostSessionConfigPicker.test.ts | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index bee227e2999fe8..cbd6795106c460 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -1251,31 +1251,50 @@ export class PickerActionViewItem extends BaseActionViewItem implements IChatInp override focus(): void { if (this._focusableElement) { this._focusableElement.focus(); + } else if (this.element) { + this._focusFirstTabStop(this.element); } else { super.focus(); } } override isFocused(): boolean { - return this._focusableElement - ? this._focusableElement === dom.getActiveElement() + return this.element + ? dom.isAncestorOfActiveElement(this.element) : super.isFocused(); } override blur(): void { - if (this._focusableElement) { - this._focusableElement.blur(); + const activeElement = dom.getActiveElement(); + if (this.element && dom.isHTMLElement(activeElement) && dom.isAncestor(activeElement, this.element)) { + activeElement.blur(); } else { super.blur(); } } - override setFocusable(focusable: boolean): void { - if (this._focusableElement) { - this.element?.removeAttribute('tabindex'); - } else { - super.setFocusable(focusable); + override setFocusable(_focusable: boolean): void { + if (this.element) { + this.element.tabIndex = -1; + } + } + + private _focusFirstTabStop(container: HTMLElement): boolean { + for (const child of container.children) { + if (!dom.isHTMLElement(child)) { + continue; + } + if (child.tabIndex >= 0) { + child.focus(); + if (dom.isActiveElement(child)) { + return true; + } + } + if (this._focusFirstTabStop(child)) { + return true; + } } + return false; } isCompact(): boolean { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 5037fadd3b1b23..07dd66f93b2232 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -372,6 +372,8 @@ suite('Agent Host Session Config Picker', () => { store.add(toDisposable(() => container.remove())); const overflowAnchor = document.createElement('button'); item.render(container); + item.setFocusable(true); + item.focus(); const expanded = { compact: item.isCompact(), className: container.classList.contains('compact-picker'), @@ -403,6 +405,42 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('picker action view items delegate focus to nested controls', () => { + let pickerContainer: HTMLElement | undefined; + const item = store.add(new PickerActionViewItem({ + render: container => { + pickerContainer = document.createElement('div'); + container.appendChild(pickerContainer); + }, + dispose: () => { }, + })); + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + item.render(container); + + item.setFocusable(true); + const focusTarget = document.createElement('button'); + focusTarget.tabIndex = 0; + pickerContainer?.appendChild(focusTarget); + item.focus(); + const focused = { + wrapperTabIndex: container.tabIndex, + focusedInnerControl: document.activeElement === focusTarget, + itemFocused: item.isFocused(), + }; + item.blur(); + + assert.deepStrictEqual({ focused, focusedAfterBlur: document.activeElement === focusTarget }, { + focused: { + wrapperTabIndex: -1, + focusedInnerControl: true, + itemFocused: true, + }, + focusedAfterBlur: false, + }); + }); + test('generic auto-approve chips retain their contextual accessible name', () => { const services = setupServices(store); const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); From 3e807d95f1dbe022ee1d72e43de6555c2d71ae5b Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 21:11:51 +0200 Subject: [PATCH 34/44] Unify multi-diff editor presentation variants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/hideUnchangedRegionsFeature.ts | 2 +- .../browser/widget/diffEditor/style.css | 4 + .../multiDiffEditor/diffEditorItemTemplate.ts | 20 +- .../multiDiffEditor/multiDiffEditorOptions.ts | 45 +++ .../multiDiffEditor/multiDiffEditorWidget.ts | 7 +- .../multiDiffEditorWidgetImpl.ts | 9 +- .../browser/widget/multiDiffEditor/style.css | 373 ++++++++++++++---- .../workbenchUIElementFactory.ts | 17 +- .../standalone/browser/standaloneEditor.ts | 3 +- .../widget/multiDiffEditorWidget.test.ts | 33 +- .../changes/browser/changes.contribution.ts | 1 - .../browser/media/multiFileDiffEditor.css | 273 ------------- .../browser/media/sessionChangesEditor.css | 48 +++ .../changes/browser/sessionChangesEditor.ts | 35 +- .../test/browser/agentsDiffEditor.fixture.ts | 40 +- .../browser/multiDiffEditor.ts | 11 +- .../browser/diff/notebookMultiDiffEditor.ts | 3 +- .../editor/multiDiffEditorFixtureUtils.ts | 3 +- 18 files changed, 505 insertions(+), 422 deletions(-) create mode 100644 src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css diff --git a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts index f5a7a9b95fb574..cb8eca88df45dd 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts @@ -308,7 +308,7 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { private readonly _nodes = h('div.diff-hidden-lines', [ h('div.top@top', { title: localize('diff.hiddenLines.top', 'Click or drag to show more above') }), h('div.center@content', { style: { display: 'flex' } }, [ - h('div@first', { style: { display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: '0' } }, + h('div.first@first', { style: { display: 'flex', alignItems: 'center', flexShrink: '0' } }, [$('a', { title: localize('showUnchangedRegion', 'Show Unchanged Region'), role: 'button', onclick: () => { this._unchangedRegion.showAll(undefined); } }, ...renderLabelWithIcons('$(unfold)'))] ), diff --git a/src/vs/editor/browser/widget/diffEditor/style.css b/src/vs/editor/browser/widget/diffEditor/style.css index 6e27efbe75aeed..7ef8d1d64ea2b8 100644 --- a/src/vs/editor/browser/widget/diffEditor/style.css +++ b/src/vs/editor/browser/widget/diffEditor/style.css @@ -84,6 +84,10 @@ box-shadow: inset 0 -5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow), inset 0 5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow); } +.monaco-editor .diff-hidden-lines .first { + justify-content: center; +} + .monaco-editor .diff-hidden-lines .center span.codicon { vertical-align: middle; } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index fb08a7df208f0a..f9a5cb8df3b655 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -22,6 +22,7 @@ import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { observableCodeEditor } from '../../observableCodeEditor.js'; import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import { DocumentDiffItemViewModel } from './multiDiffEditorViewModel.js'; +import { IMultiDiffEditorVariantConfiguration } from './multiDiffEditorOptions.js'; import { ActionRunnerWithContext } from './utils.js'; import { IVirtualizedItemBindingContext, VirtualizedItemBinding, VirtualizedItemTemplate } from './virtualizedItemManager.js'; import { IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from './workbenchUIElementFactory.js'; @@ -69,6 +70,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService _parentContextKeyService: IContextKeyService, @@ -77,7 +79,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate(this, undefined); this._collapsed = derived(this, reader => this._viewModel.read(reader)?.collapsed.read(reader)); this._editorContentHeight = observableValue(this, 500); - this._itemHorizontalInsets = this._workbenchUIElementFactory.diffEditorItemHorizontalInsets ?? { left: 9, right: 9 }; + this._itemHorizontalInsets = this._variantConfiguration.horizontalInsets; this.size = derived(this, reader => { if (this._collapsed.read(reader)) { return this._headerHeight; @@ -160,14 +162,18 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._viewModel.get()?.setActive(undefined); @@ -195,7 +201,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; + readonly headerHeight: number; + readonly contentBottomPadding: number; + readonly headerClickToCollapse: boolean; +} + +export function getMultiDiffEditorVariantConfiguration(variant: MultiDiffEditorVariant): IMultiDiffEditorVariantConfiguration { + switch (variant) { + case MultiDiffEditorVariant.Standard: + return { + className: 'multiDiffEditor-standard', + horizontalInsets: { left: 9, right: 9 }, + headerHeight: 40, + contentBottomPadding: 0, + headerClickToCollapse: false, + }; + case MultiDiffEditorVariant.Compact: + return { + className: 'multiDiffEditor-compact', + horizontalInsets: { left: 0, right: 0 }, + headerHeight: 32, + contentBottomPadding: 8, + headerClickToCollapse: true, + }; + } +} diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 51779dfb0d0281..3ea93e4e8181e9 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -20,6 +20,7 @@ import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; +import { getMultiDiffEditorVariantConfiguration, IMultiDiffEditorWidgetOptions } from './multiDiffEditorOptions.js'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; @@ -29,6 +30,7 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _viewModel = observableValue(this, undefined); private readonly _diffLayoutOptions = observableValue(this, undefined); private readonly _paddingBottomPx = observableValue(this, 0); + private readonly _variantConfiguration = getMultiDiffEditorVariantConfiguration(this._options.variant); private readonly _widgetImpl = derived(this, (reader) => { readHotReloadableExport(DiffEditorItemTemplate, reader); @@ -38,8 +40,9 @@ export class MultiDiffEditorWidget extends Disposable { this._dimension, this._viewModel, this._workbenchUIElementFactory, + this._variantConfiguration, this._diffLayoutOptions, - this._diffEditorOptions, + this._options.diffEditorOptions, this._paddingBottomPx, )); }); @@ -47,7 +50,7 @@ export class MultiDiffEditorWidget extends Disposable { constructor( private readonly _element: HTMLElement, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, - private readonly _diffEditorOptions: IDiffEditorOptions | undefined, + private readonly _options: IMultiDiffEditorWidgetOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 3da1bfe43a3393..707977dd82b4fc 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -26,6 +26,7 @@ import { ICompressedVirtualizedScrollLayout } from './compressedVirtualizedScrol import { binaryFilePlaceholderContentHeight, DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; +import { IMultiDiffEditorVariantConfiguration } from './multiDiffEditorOptions.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { RevealOptions } from './multiDiffEditorWidget.js'; import './style.css'; @@ -69,6 +70,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _dimension: IObservable, private readonly _viewModel: IObservable, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, + private readonly _variantConfiguration: IMultiDiffEditorVariantConfiguration, private readonly _diffLayoutOptions: IObservable, private readonly _diffEditorOptions: IDiffEditorOptions | undefined, private readonly _paddingBottomPx: IObservable, @@ -101,13 +103,13 @@ export class MultiDiffEditorWidgetImpl extends Disposable { getId: item => item, getTemplateId: () => 'diffEditor', getUnboundSize: item => derived(item, reader => { - const headerHeight = this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40; + const headerHeight = this._variantConfiguration.headerHeight; if (item.collapsed.read(reader)) { return headerHeight; } if (item.isBinary) { return headerHeight - + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0) + + this._variantConfiguration.contentBottomPadding + binaryFilePlaceholderContentHeight; } return item.lastTemplateData.read(reader).expandedContentHeight; @@ -117,6 +119,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { context.contentDomNode, context.overflowWidgetsDomNode, this._workbenchUIElementFactory, + this._variantConfiguration, this._optionsOverride, ), onDidBind: binding => { @@ -197,7 +200,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { items: items.map((item, index) => item.getLayoutDebugState(reader, layout.items[index])), }; }); - this._elements = h('div.monaco-component.multiDiffEditor', {}, [ + this._elements = h(`div.monaco-component.multiDiffEditor.${this._variantConfiguration.className}`, {}, [ this._scrollView.domNode, h('div.placeholder@placeholder', {}, [h('div')]), ]); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css index 138aa773b0b21b..a1b9a0e4f63379 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -4,42 +4,34 @@ *--------------------------------------------------------------------------------------------*/ .monaco-component.multiDiffEditor { - background: var(--vscode-multiDiffEditor-background); - position: relative; - - height: 100%; width: 100%; - + height: 100%; overflow-y: hidden; + background: var(--vscode-multiDiffEditor-background); > div { position: absolute; - top: 0px; - left: 0px; - - height: 100%; + top: 0; + left: 0; width: 100%; + height: 100%; &.placeholder { + display: grid; + place-items: center; + place-content: center; visibility: hidden; &.visible { visibility: visible; } - - display: grid; - place-items: center; - place-content: center; } } > .multi-diff-root-floating-menu { position: absolute; - top: auto; - right: 28px; - bottom: 24px; - left: auto; + inset: auto var(--vscode-spacing-size280) var(--vscode-spacing-size240) auto; width: auto; } @@ -49,42 +41,24 @@ .multiDiffEntry { display: flex; - flex-direction: column; flex: 1; + flex-direction: column; overflow: hidden; - .collapse-button { - margin: 0 5px; cursor: pointer; - - a { - display: block; - } } .header { z-index: 1000; - background: var(--vscode-editor-background); - - &:not(.collapsed) .header-content { - border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); - } .header-content { - margin: 8px 0px 0px 0px; - padding: 4px 5px; - - border-top: 1px solid var(--vscode-multiDiffEditor-border); - display: flex; align-items: center; - color: var(--vscode-foreground); - background: var(--vscode-multiDiffEditor-headerBackground); &.shadow { - box-shadow: var(--vscode-scrollbar-shadow) 0px 6px 6px -6px; + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px; } .file-path { @@ -94,60 +68,46 @@ overflow: hidden; .title { - font-size: 14px; - line-height: 22px; flex: 0 1 auto; min-width: 0; overflow: hidden; + line-height: 22px; text-overflow: ellipsis; - &.original { - flex: 1 1 auto; + &.modified { + display: flex; + align-items: center; } - } - .status { - font-weight: 600; - opacity: 0.75; - margin: 0px 10px; - line-height: 22px; - - /* - TODO@hediet: move colors from git extension to core! - &.renamed { - color: v ar(--vscode-gitDecoration-renamedResourceForeground); + &.modified > .monaco-icon-label { + flex: 0 1 auto; + min-width: 0; } - &.deleted { - color: v ar(--vscode-gitDecoration-deletedResourceForeground); + &.original { + flex: 1 1 auto; } + } - &.added { - color: v ar(--vscode-gitDecoration-addedResourceForeground); - } - */ + .multi-diff-resource-label-accessory:empty { + display: none; + } - &:not(.added):not(.deleted):not(.renamed) { - display: none; - } + .status:not(.added):not(.deleted):not(.renamed) { + display: none; } } .actions { flex: 0 0 auto; - padding: 0 8px; } } - - } .editorParent { - flex: 1; display: flex; + flex: 1; flex-direction: column; - - border-bottom: 1px solid var(--vscode-multiDiffEditor-border); overflow: hidden; } @@ -174,4 +134,283 @@ } } } + + &.multiDiffEditor-standard { + .multiDiffEntry { + .collapse-button { + margin: 0 5px; + + a { + display: block; + } + } + + .header { + background: var(--vscode-editor-background); + + &:not(.collapsed) .header-content { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-sideBarSectionHeader-border); + } + + .header-content { + margin: var(--vscode-spacing-size80) 0 0; + padding: var(--vscode-spacing-size40) 5px; + border-top: var(--vscode-strokeThickness) solid var(--vscode-multiDiffEditor-border); + background: var(--vscode-multiDiffEditor-headerBackground); + + .file-path { + .title { + font-size: 14px; + } + + .status { + margin: 0 var(--vscode-spacing-size100); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 22px; + opacity: 0.75; + } + } + + .actions { + padding: 0 var(--vscode-spacing-size80); + } + } + } + + .editorParent { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-multiDiffEditor-border); + } + } + } + + &.multiDiffEditor-compact { + .multiDiffEntry { + box-sizing: border-box; + + &::after { + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + z-index: 1001; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &.header-hovered, + &.header-focused { + z-index: 1002; + overflow: visible; + } + + &.header-hovered::after, + &.header-focused::after { + right: 0; + left: 0; + z-index: 1004; + } + + &.header-hovered::before, + &.header-focused::before { + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + z-index: 1004; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &.first-diff-entry.header-hovered::before, + &.first-diff-entry.header-focused::before { + top: 0; + } + + .collapse-button { + margin: 0 var(--vscode-spacing-size60); + + a { + display: flex; + align-items: center; + justify-content: center; + padding: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-medium); + + &:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + &:active { + background: var(--vscode-toolbar-activeBackground); + } + + &:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + &:focus:not(:focus-visible) { + outline: none; + } + } + } + + .header { + position: relative; + background: var(--vscode-multiDiffEditor-background); + + &[role='button'] { + cursor: pointer; + } + + .header-content { + box-sizing: border-box; + height: var(--vscode-spacing-size320); + margin: 0; + padding: 0; + border: none; + background: transparent; + + .file-path { + .title { + font-size: var(--vscode-fontSize-body1); + } + + .status { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size160); + min-width: var(--vscode-spacing-size160); + margin: 0 var(--vscode-spacing-size100); + font-size: var(--vscode-fontSize-body2); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 22px; + opacity: 0.9; + + &.added { + color: var(--vscode-gitDecoration-addedResourceForeground); + } + + &.deleted { + color: var(--vscode-gitDecoration-deletedResourceForeground); + } + + &.renamed { + color: var(--vscode-gitDecoration-modifiedResourceForeground); + } + } + } + + .actions { + padding: 0 var(--vscode-spacing-size80); + + .actions-container { + gap: var(--vscode-spacing-size40); + + .action-item:not(.multi-diff-action-always-visible) { + visibility: hidden; + } + + .action-item:not(.checkbox-action-item) .action-label { + padding: var(--vscode-spacing-size40); + } + } + } + } + + &:hover .header-content { + background: var(--vscode-list-hoverBackground); + } + + &:active .header-content { + background: var(--vscode-toolbar-activeBackground); + } + + &[aria-expanded='false'] + .editorParent { + border: none; + } + + &:focus { + outline: none; + } + + &:focus-visible .header-content { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + } + + &:hover .header .header-content .actions .actions-container .action-item, + &:focus-within .header .header-content .actions .actions-container .action-item { + visibility: visible; + } + + .editorParent { + border: none; + } + } + + &:focus-within .multiDiffEntry.active { + z-index: 1002; + overflow: visible; + } + + &:focus-within .multiDiffEntry.active::after { + right: 0; + left: 0; + z-index: 1004; + } + + &:focus-within .multiDiffEntry.active::before { + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + z-index: 1004; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &:focus-within .multiDiffEntry.first-diff-entry.active::before { + top: 0; + } + + &:focus-within .multiDiffEntry.active .header .header-content, + &:focus-within .multiDiffEntry.active .header:hover .header-content, + &:focus-within .multiDiffEntry.active .header:active .header-content { + background: var(--vscode-list-inactiveSelectionBackground); + } + + .diff-hidden-lines .center { + overflow: hidden; + box-shadow: none; + } + + .diff-hidden-lines .first { + box-sizing: border-box; + justify-content: flex-start; + padding-left: var(--vscode-spacing-size60); + } + + .fold-unchanged { + box-sizing: border-box; + } + + .editor.original .diff-hidden-lines .center { + border-top-left-radius: var(--vscode-cornerRadius-medium); + border-bottom-left-radius: var(--vscode-cornerRadius-medium); + } + + .editor.modified .diff-hidden-lines .center { + border-top-right-radius: var(--vscode-cornerRadius-medium); + border-bottom-right-radius: var(--vscode-cornerRadius-medium); + } + } } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index a5f61ea899d1c7..ae08f660391387 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -26,22 +26,7 @@ export const enum MultiDiffEditorItemLabelKind { * This would make monaco-editor consumption much more difficult though. */ export interface IWorkbenchUIElementFactory { - createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel; - - /** Horizontal insets reserved around each embedded diff editor. */ - readonly diffEditorItemHorizontalInsets?: Readonly<{ left: number; right: number }>; - - /** Height of each entry's file header, in px. Defaults to 40. */ - readonly diffEditorItemHeaderHeight?: number; - - /** Padding reserved below each embedded diff editor, in px. Defaults to 0. */ - readonly diffEditorItemContentBottomPadding?: number; - - /** - * When true, the entire header area is clickable to toggle collapse/expand - * and receives keyboard activation (Enter/Space) and ARIA button semantics. - */ - readonly headerClickToCollapse?: boolean; + createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind, accessoryContainer: HTMLElement): IResourceLabel; /** Handles a middle-click on an entry header. Returns whether the event was handled. */ handleHeaderMiddleClick?(resource: URI): boolean; diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index a454f5b1118735..36f1092b3c8806 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -39,6 +39,7 @@ import { IKeybindingService } from '../../../platform/keybinding/common/keybindi import { IMarker, IMarkerData, IMarkerService } from '../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { MultiDiffEditorWidget } from '../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; /** @@ -102,7 +103,7 @@ export function createDiffEditor(domElement: HTMLElement, options?: IStandaloneD export function createMultiFileDiffEditor(domElement: HTMLElement, override?: IEditorOverrideServices) { const instantiationService = StandaloneServices.initialize(override || {}); - return new MultiDiffEditorWidget(domElement, {}, undefined, instantiationService); + return new MultiDiffEditorWidget(domElement, {}, { variant: MultiDiffEditorVariant.Standard }, instantiationService); } /** diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index e988b9cb967f57..96f7eff3c30109 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -23,6 +23,7 @@ import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/ import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; +import { getMultiDiffEditorVariantConfiguration, MultiDiffEditorVariant } from '../../../browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -39,6 +40,28 @@ suite('MultiDiffEditorWidget', () => { sinon.restore(); }); + test('uses closed variant configurations', () => { + assert.deepStrictEqual({ + standard: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Standard), + compact: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Compact), + }, { + standard: { + className: 'multiDiffEditor-standard', + horizontalInsets: { left: 9, right: 9 }, + headerHeight: 40, + contentBottomPadding: 0, + headerClickToCollapse: false, + }, + compact: { + className: 'multiDiffEditor-compact', + horizontalInsets: { left: 0, right: 0 }, + headerHeight: 32, + contentBottomPadding: 8, + headerClickToCollapse: true, + }, + }); + }); + test('models bottom padding as trailing scroll content', () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); @@ -61,7 +84,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const initialState = widget.getLayoutDebugState().get(); @@ -117,7 +140,7 @@ suite('MultiDiffEditorWidget', () => { { openDiffEditor: (original, modified) => openedDiff = { original, modified }, } satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); @@ -213,7 +236,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.setRenderSideBySide(true, { useInlineViewWhenSpaceIsLimited: true }); widget.layout(new Dimension(800, 600)); @@ -286,7 +309,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); @@ -366,7 +389,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const viewModel = widget.createViewModel(model); diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index 1d2c25162cdc10..0e7a942f7c58bc 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -25,7 +25,6 @@ import './changesActions.js'; import './changesViewActions.js'; import './changesetReviewActions.js'; import './checksActions.js'; -import './media/multiFileDiffEditor.css'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ChangesViewService } from './changesViewService.js'; diff --git a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css deleted file mode 100644 index e546345b978c83..00000000000000 --- a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css +++ /dev/null @@ -1,273 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Agents window styling for the multi-file diff editor (the embedded multi-diff - * used by the Changes view / editor). Kept out of the shared workbench style so - * these overrides live next to the Changes contribution that owns them. */ - -.agent-sessions-workbench .part.editor .multiDiffEntry::after { - content: ''; - position: absolute; - right: var(--vscode-spacing-size100); - bottom: 0; - left: var(--vscode-spacing-size100); - height: var(--vscode-strokeThickness); - background: var(--vscode-panel-border); - z-index: 1001; - pointer-events: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header { - cursor: pointer; - background: var(--vscode-multiDiffEditor-background); - position: relative; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active { - overflow: visible; - z-index: 1002; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::after, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::after, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::after { - right: 0; - left: 0; - z-index: 1004; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::before, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::before, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::before { - content: ''; - position: absolute; - top: calc(-1 * var(--vscode-strokeThickness)); - right: 0; - left: 0; - height: var(--vscode-strokeThickness); - background: var(--vscode-panel-border); - z-index: 1004; - pointer-events: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-hovered::before, -.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-focused::before, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.first-diff-entry.active::before { - top: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header .header-content { - height: var(--vscode-spacing-size320); - box-sizing: border-box; - border: none; - background: transparent; - margin: 0; - padding: var(--vscode-spacing-sizeNone); - align-items: center; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:not(.collapsed) .header-content { - border-bottom: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header .header-content { - background: var(--vscode-list-inactiveSelectionBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { - background: var(--vscode-list-hoverBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:active .header-content { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:hover .header-content, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:active .header-content { - background: var(--vscode-list-inactiveSelectionBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header[aria-expanded="false"] + .editorParent { - border: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title { - font-size: var(--vscode-fontSize-body1); - line-height: 22px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title.modified { - display: flex; - align-items: center; - flex: 0 1 auto; - min-width: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title.modified > .monaco-icon-label { - flex: 0 1 auto; - min-width: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats { - display: inline-flex; - align-items: center; - flex: 0 0 auto; - gap: var(--vscode-spacing-size40); - margin-left: var(--vscode-spacing-size60); - font-size: var(--vscode-fontSize-body2); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats .working-set-lines-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats .working-set-lines-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container { - gap: 4px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) { - visibility: hidden; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry:hover .header-content .actions-container .action-item:not(.changeset-review-action), -.agent-sessions-workbench .part.editor .multiDiffEntry:focus-within .header-content .actions-container .action-item:not(.changeset-review-action) { - visibility: visible; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus { - outline: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible .header-content { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - display: flex; - align-items: center; - justify-content: center; - padding: var(--vscode-spacing-size40); - border-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) .action-label { - padding: var(--vscode-spacing-size40); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:hover { - background: var(--vscode-toolbar-hoverBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:active { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus:not(:focus-visible) { - outline: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { - border: none; - overflow: hidden; -} - -.agent-sessions-workbench .part.editor .diff-hidden-lines .center { - box-shadow: none; - overflow: hidden; -} - -/* Left-align the hidden-lines expand/collapse control (`$(unfold)`) so it lines - * up with the file header twistie, instead of being centered in the original - * line-number gutter width (which pushes it to the right). */ -.agent-sessions-workbench .part.editor .diff-hidden-lines .center > div:first-child { - justify-content: flex-start !important; - padding-left: var(--vscode-spacing-size60); - box-sizing: border-box; -} - -/* Align the "Fold Unchanged Region" glyph-margin control with the same offset so - * the collapse (fold) and expand (unfold) controls share the header twistie's - * horizontal position. */ -.agent-sessions-workbench .part.editor .fold-unchanged { - box-sizing: border-box; -} - -/* The hidden-lines bar is split into two halves (original / modified) sitting - * side by side. Round only the outer corners so the pair forms a single pill. */ -.agent-sessions-workbench .part.editor .editor.original .diff-hidden-lines .center { - border-top-left-radius: var(--vscode-cornerRadius-medium); - border-bottom-left-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .editor.modified .diff-hidden-lines .center { - border-top-right-radius: var(--vscode-cornerRadius-medium); - border-bottom-right-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - min-width: 16px; - font-size: var(--vscode-fontSize-body2); - font-weight: var(--vscode-fontWeight-semiBold); - opacity: 0.9; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.renamed { - color: var(--vscode-gitDecoration-modifiedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .action-item.checkbox-action-item.changeset-review-action { - display: flex; - align-items: center; - padding: 0 4px; - cursor: pointer; - color: var(--vscode-foreground); - border-radius: var(--vscode-cornerRadius-small); - border: var(--vscode-strokeThickness) solid var(--vscode-toolbar-hoverBackground); - height: 20px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .action-item.checkbox-action-item.changeset-review-action:has(> .monaco-checkbox.checked) { - background: var(--vscode-actionBar-toggledBackground) !important; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action .checkbox-label { - font-size: var(--vscode-fontSize-body2); - line-height: 20px; - white-space: nowrap; - cursor: pointer; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action.disabled { - opacity: 0.5; - cursor: default; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action.disabled .checkbox-label { - cursor: default; -} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css index e0ef7f03e2b8d4..74b519ce1dc627 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -11,6 +11,54 @@ color: var(--vscode-agentsPanel-foreground); } +.session-changes-file-stats { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--vscode-spacing-size40); + margin-left: var(--vscode-spacing-size60); + font-size: var(--vscode-fontSize-body2); +} + +.session-changes-file-stats .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.session-changes-file-stats .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} + +.changeset-review-action { + display: flex; + align-items: center; + height: var(--vscode-spacing-size200); + padding: 0 var(--vscode-spacing-size40); + cursor: pointer; + color: var(--vscode-foreground); + border: var(--vscode-strokeThickness) solid var(--vscode-toolbar-hoverBackground); + border-radius: var(--vscode-cornerRadius-small); +} + +.changeset-review-action.checked { + background: var(--vscode-actionBar-toggledBackground); +} + +.changeset-review-action .checkbox-label { + font-size: var(--vscode-fontSize-body2); + line-height: var(--vscode-spacing-size200); + white-space: nowrap; + cursor: pointer; +} + +.changeset-review-action.disabled { + cursor: default; + opacity: 0.5; +} + +.changeset-review-action.disabled .checkbox-label { + cursor: default; +} + .session-changes-editor-header { display: flex; align-items: center; diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 2f296da8bebfa9..f6699a9159688d 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -30,6 +30,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; @@ -69,16 +70,8 @@ const CHANGES_DIFF_EDITOR_OPTIONS: IDiffEditorOptions = { }; const CHANGES_LIST_BOTTOM_PADDING_PX = 24; -const CHANGES_ENTRY_HEADER_HEIGHT_PX = 32; -const CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX = 8; - class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse = true; - readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; - readonly diffEditorItemHeaderHeight = CHANGES_ENTRY_HEADER_HEIGHT_PX; - readonly diffEditorItemContentBottomPadding = CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX; - constructor( private readonly changesObs: IObservable, @ICommandService private readonly commandService: ICommandService, @@ -87,10 +80,10 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { @IEditorService private readonly editorService: IEditorService, ) { } - createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel { + createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind, accessoryContainer: HTMLElement): IResourceLabel { const label = this.instantiationService.createInstance(ResourceLabel, element, {}); const showDiffStats = kind === MultiDiffEditorItemLabelKind.Primary; - return new SessionChangesResourceLabel(label, element, showDiffStats, this.changesObs); + return new SessionChangesResourceLabel(label, accessoryContainer, showDiffStats, this.changesObs); } handleHeaderMiddleClick(resource: URI): boolean { @@ -129,7 +122,7 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { constructor( private readonly label: ResourceLabel, - element: HTMLElement, + accessoryContainer: HTMLElement, showDiffStats: boolean, changesObs: IObservable, ) { @@ -137,9 +130,9 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { this._register(label); if (showDiffStats) { - const statsContainer = append(element, $('.session-changes-file-stats')); - const added = append(statsContainer, $('.working-set-lines-added')); - const removed = append(statsContainer, $('.working-set-lines-removed')); + accessoryContainer.classList.add('session-changes-file-stats'); + const added = append(accessoryContainer, $('.working-set-lines-added')); + const removed = append(accessoryContainer, $('.working-set-lines-removed')); added.setAttribute('aria-hidden', 'true'); removed.setAttribute('aria-hidden', 'true'); @@ -148,15 +141,15 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { const stats = resource ? getChangesEditorFileStats(resource, changesObs.read(reader)) : undefined; - statsContainer.style.display = stats ? '' : 'none'; + accessoryContainer.style.display = stats ? '' : 'none'; if (stats) { added.textContent = `+${stats.insertions}`; removed.textContent = `-${stats.deletions}`; - statsContainer.setAttribute('aria-label', localize('sessionChangesEditor.fileCounts', '{0} lines added, {1} lines removed', stats.insertions, stats.deletions)); + accessoryContainer.setAttribute('aria-label', localize('sessionChangesEditor.fileCounts', '{0} lines added, {1} lines removed', stats.insertions, stats.deletions)); } else { added.textContent = ''; removed.textContent = ''; - statsContainer.removeAttribute('aria-label'); + accessoryContainer.removeAttribute('aria-label'); } })); } @@ -276,7 +269,10 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { @@ -486,12 +482,13 @@ class ChangesetReviewActionViewItem extends CheckboxActionViewItem { override render(container: HTMLElement): void { super.render(container); - container.classList.add('changeset-review-action'); + container.classList.add('changeset-review-action', 'multi-diff-action-always-visible'); } override updateChecked(): void { super.updateChecked(); + this.element?.classList.toggle('checked', !!this.action.checked); this.updateAriaLabel(); this.updateTooltip(); } diff --git a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts index f0119da5383257..647788c946cdd3 100644 --- a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts +++ b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import '../../browser/media/multiFileDiffEditor.css'; +import '../../browser/media/sessionChangesEditor.css'; import '../../../agentFeedback/browser/media/agentFeedbackEditorInput.css'; import '../../../../../base/browser/ui/codicons/codiconStyles.js'; import { $, Dimension, getWindow } from '../../../../../base/browser/dom.js'; @@ -15,6 +15,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; @@ -60,13 +61,6 @@ class FixtureAgentFeedbackMenuService implements IMenuService { ) { } createMenu(id: MenuId): IMenu { - if (id !== Menus.AgentFeedbackEditorContent) { - return { - onDidChange: Event.None, - dispose: () => { }, - getActions: () => [], - }; - } const createAction = (actionId: string, title: string, icon: ThemeIcon) => this.instantiationService.createInstance( MenuItemAction, { id: actionId, title, icon }, @@ -75,6 +69,20 @@ class FixtureAgentFeedbackMenuService implements IMenuService { undefined, undefined, ); + if (id === MenuId.MultiDiffEditorFileToolbar) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [['navigation', [createAction('fixture.expandFullFile', 'Expand Full File', Codicon.unfold)]]], + }; + } + if (id !== Menus.AgentFeedbackEditorContent) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [], + }; + } const navigateActions = [ createAction(navigationBearingFakeActionId, 'Navigation Status', Codicon.commentDiscussion), createAction(navigatePreviousFeedbackActionId, 'Previous', Codicon.arrowUp), @@ -101,11 +109,6 @@ class FixtureAgentFeedbackMenuService implements IMenuService { class AgentsDiffUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse = true; - readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; - readonly diffEditorItemHeaderHeight = 32; - readonly diffEditorItemContentBottomPadding = 8; - constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } @@ -273,10 +276,13 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt editorInstance, instantiationService.createInstance(AgentsDiffUIElementFactory), { - hideOriginalLineNumbers: true, - folding: false, - hideUnchangedRegions: { enabled: true }, - lineNumbersMinChars: 3, + variant: MultiDiffEditorVariant.Compact, + diffEditorOptions: { + hideOriginalLineNumbers: true, + folding: false, + hideUnchangedRegions: { enabled: true }, + lineNumbersMinChars: 3, + }, }, )); widget.setRenderSideBySide(false); diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index a82ed9620aba8e..7e03fe3b97d461 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; import { IResourceLabel, IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; @@ -18,7 +19,6 @@ import { IStorageService } from '../../../../platform/storage/common/storage.js' import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ResourceLabel } from '../../../browser/labels.js'; -import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { AbstractEditorWithViewState } from '../../../browser/parts/editor/editorWithViewState.js'; import { ICompositeControl } from '../../../common/composite.js'; import { IEditorOpenContext } from '../../../common/editor.js'; @@ -84,7 +84,7 @@ export class MultiDiffEditor extends AbstractEditorWithViewState { @@ -244,15 +244,10 @@ class MultiDiffEditorContentMenuOverlay extends Disposable { } class WorkbenchUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse: boolean; - constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, @IEditorService private readonly editorService: IEditorService, - ) { - this.headerClickToCollapse = IsSessionsWindowContext.getValue(contextKeyService) === true; - } + ) { } createResourceLabel(element: HTMLElement, _kind: MultiDiffEditorItemLabelKind): IResourceLabel { const label = this._instantiationService.createInstance(ResourceLabel, element, {}); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts index 87ee014e9f7c60..1a5cf832bc67c4 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts @@ -28,6 +28,7 @@ import { NotebookOptions } from '../notebookOptions.js'; import { INotebookService } from '../../common/notebookService.js'; import { NotebookMultiDiffEditorInput, NotebookMultiDiffEditorWidgetInput } from './notebookMultiDiffEditorInput.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { ResourceLabel } from '../../../../browser/labels.js'; import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { localize } from '../../../../../nls.js'; @@ -103,7 +104,7 @@ export class NotebookMultiTextDiffEditor extends EditorPane { MultiDiffEditorWidget, parent, this.instantiationService.createInstance(WorkbenchUIElementFactory), - undefined, + { variant: MultiDiffEditorVariant.Standard }, )); this._register(this._multiDiffEditorWidget.onDidChangeActiveControl(() => { diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts index f059d86a8b6266..f490c02930bcf3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts @@ -10,6 +10,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel as IMultiDiffResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; @@ -128,7 +129,7 @@ export function createMultiDiffEditorFixtureWidget(instantiationService: IInstan MultiDiffEditorWidget, container, uiFactory, - diffEditorOptions, + { variant: MultiDiffEditorVariant.Compact, diffEditorOptions }, ); } From af085bcf15adf6a2aaecd4c8ea482c6da074588c Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 21:30:42 +0200 Subject: [PATCH 35/44] Fix multi-diff widget field initialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../multiDiffEditor/multiDiffEditorWidget.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 3ea93e4e8181e9..24f11fe4ccd1d9 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -20,7 +20,7 @@ import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; -import { getMultiDiffEditorVariantConfiguration, IMultiDiffEditorWidgetOptions } from './multiDiffEditorOptions.js'; +import { getMultiDiffEditorVariantConfiguration, IMultiDiffEditorVariantConfiguration, IMultiDiffEditorWidgetOptions } from './multiDiffEditorOptions.js'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; @@ -30,22 +30,8 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _viewModel = observableValue(this, undefined); private readonly _diffLayoutOptions = observableValue(this, undefined); private readonly _paddingBottomPx = observableValue(this, 0); - private readonly _variantConfiguration = getMultiDiffEditorVariantConfiguration(this._options.variant); - - private readonly _widgetImpl = derived(this, (reader) => { - readHotReloadableExport(DiffEditorItemTemplate, reader); - return reader.store.add(this._instantiationService.createInstance(( - readHotReloadableExport(MultiDiffEditorWidgetImpl, reader)), - this._element, - this._dimension, - this._viewModel, - this._workbenchUIElementFactory, - this._variantConfiguration, - this._diffLayoutOptions, - this._options.diffEditorOptions, - this._paddingBottomPx, - )); - }); + private readonly _variantConfiguration: IMultiDiffEditorVariantConfiguration; + private readonly _widgetImpl: IObservable; constructor( private readonly _element: HTMLElement, @@ -55,6 +41,21 @@ export class MultiDiffEditorWidget extends Disposable { ) { super(); + this._variantConfiguration = getMultiDiffEditorVariantConfiguration(this._options.variant); + this._widgetImpl = derived(this, reader => { + readHotReloadableExport(DiffEditorItemTemplate, reader); + return reader.store.add(this._instantiationService.createInstance(( + readHotReloadableExport(MultiDiffEditorWidgetImpl, reader)), + this._element, + this._dimension, + this._viewModel, + this._workbenchUIElementFactory, + this._variantConfiguration, + this._diffLayoutOptions, + this._options.diffEditorOptions, + this._paddingBottomPx, + )); + }); this._register(recomputeInitiallyAndOnChange(this._widgetImpl)); } From 62bdc0baa8e1bc484105307259aa1a706e64ee87 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 3 Sep 2026 22:47:00 +0200 Subject: [PATCH 36/44] Update Markdown editor to 0.0.2-88 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cf6761b4-5164-41c7-8123-5da286a9798a --- extensions/markdown-language-features/package-lock.json | 8 ++++---- extensions/markdown-language-features/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index bca87cd3d18dd8..5a14bcc4bada2f 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-87", + "@vscode/markdown-editor": "^0.0.2-88", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -633,9 +633,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-87", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-87.tgz", - "integrity": "sha512-c1T5c2p2btf8NGVARkDN/qGezyZ25xCL/2KXqwsQeh9NmcH4bPZi2CtaeaLmQU2EyHS7WpdYKcIdwGMrJstLEg==", + "version": "0.0.2-88", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-88.tgz", + "integrity": "sha512-vN4vtf1QRF79BKZBA3ufuluFgqeb3uASOnsNcmm2bDHxZjgeRLiji+fgTkP//I+Qg/A68lelk/8K6SMQ933bTA==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 998abf85286920..c51f87715c44ef 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1791,7 +1791,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-87", + "@vscode/markdown-editor": "^0.0.2-88", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", From b04dcb38420772849c7f65f8b9d53b9dbbe3ae9e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:17:28 +0000 Subject: [PATCH 37/44] Fix CMD/bat comment spacing (#334205) * Initial plan * Fix BAT comment spacing Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --- extensions/bat/language-configuration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/bat/language-configuration.json b/extensions/bat/language-configuration.json index 1a09da07381c34..1bf5de59ce72c7 100644 --- a/extensions/bat/language-configuration.json +++ b/extensions/bat/language-configuration.json @@ -1,6 +1,6 @@ { "comments": { - "lineComment": "@REM" + "lineComment": "@REM " }, "brackets": [ ["{", "}"], From ac067516e30a1fd5e4fde1dd9dab1e8f9df32a84 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 17:17:43 -0400 Subject: [PATCH 38/44] sessions: Add no-workspace option to new session picker (#334356) * sessions: Add no-workspace option to new session picker Offer a workspace-less quick chat from the Agents Window workspace picker when consolidated remote workspaces are enabled, while preserving the existing picker presentation and safely cancelling pending workspace drafts.\n\nFixes #334345\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Gate no-workspace option on quick chat support Keep the option available for an existing workspace-less draft, but do not offer it when no provider can create quick chats.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Fix no-workspace test on web Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Scope picker visibility to no-workspace choice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatWidget.ts | 84 ++++++++--- .../chat/browser/sessionWorkspacePicker.ts | 72 ++++++++-- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../chat/test/browser/newChatWidget.test.ts | 131 ++++++++++++++++++ .../browser/sessionWorkspacePicker.test.ts | 59 ++++++++ .../chat/browser/chat.shared.contribution.ts | 2 +- 6 files changed, 318 insertions(+), 32 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 3099ac24e9f4dd..74aae8f54b12bd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -10,7 +10,7 @@ import { Action } from '../../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -22,13 +22,13 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { localize } from '../../../../nls.js'; -import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ICreateNewSessionOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { isAllowSignedOutWhenUsableEnabled, shouldShowGitHubWorkspaceGroupSignIn } from '../../../browser/sessionsAuthGate.js'; import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; -import { IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; +import { IWorkspacePickerNoWorkspaceOption, IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; import { IPreferredSessionType } from './sessionTypePicker.js'; import { NewChatInputWidget } from './newChatInput.js'; @@ -46,7 +46,7 @@ import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from ' import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatTipService } from '../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; @@ -83,8 +83,11 @@ export class NewChatWidget extends Disposable { private readonly _session: IObservable; - /** Whether the active draft is a workspace-less quick chat (hides the workspace picker). */ + /** Whether the active draft is a workspace-less quick chat. */ private readonly _isQuickChatComposer: IObservable; + private readonly _isWorkspacePickerQuickChat: IObservable; + private readonly _workspacePickerQuickChatSessionId = observableValue(this, undefined); + private readonly _useConsolidatedRemoteWorkspaces: IObservable; /** Draft comments shared by every uncreated new-session composer. */ private readonly _feedbackItems: IObservable; @@ -143,6 +146,15 @@ export class NewChatWidget extends Disposable { const session = this._session.read(reader); return session?.isQuickChat?.read(reader) ?? false; }); + this._isWorkspacePickerQuickChat = derived(this, reader => { + const session = this._session.read(reader); + return !!session?.isQuickChat?.read(reader) && session.sessionId === this._workspacePickerQuickChatSessionId.read(reader); + }); + this._useConsolidatedRemoteWorkspaces = observableFromEvent( + this, + Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.ConsolidatedRemoteWorkspaces)), + () => this.configurationService.getValue(ChatConfiguration.ConsolidatedRemoteWorkspaces), + ); // On web (vscode.dev / insiders.vscode.dev), use {@link WebWorkspacePicker} // which scopes recents to the active host and renders as a bottom @@ -165,6 +177,7 @@ export class NewChatWidget extends Disposable { } return undefined; }, + getNoWorkspaceOption: () => this._getNoWorkspaceOption(), })); const feedbackChanged = observableSignalFromEvent(this, this.agentFeedbackService.onDidChangeFeedback); @@ -297,7 +310,7 @@ export class NewChatWidget extends Disposable { // A quick chat has no folder: re-create the draft with the picked // type via openQuickChat (mirrors the folder path's draft recreation). if (this._isQuickChatComposer.get()) { - this.sessionsService.openQuickChat(pick ? { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId } : undefined); + this._openQuickChat(pick ? { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId } : undefined); this._newChatInput.focus(); return; } @@ -450,22 +463,25 @@ export class NewChatWidget extends Disposable { this._newChatInput.noticeHost, ); - // Quick chat composer: hide the workspace picker for workspace-less - // drafts (there is nothing to pick) and reflect it in the picker-visible - // context key. Quick chats are only created on desktop (the local agent - // host), so leave the web empty-state gate's key management untouched. + // Quick chat composer: retain the picker only when it created the + // workspace-less draft, so the user can switch back to a workspace. + // Quick chats are only created on desktop (the local agent host), so + // leave the web empty-state gate's key management untouched. this._register(autorun(reader => { const isQuickChat = this._isQuickChatComposer.read(reader); - chatWidgetContent.classList.toggle('quick-chat', isQuickChat); + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.read(reader); + chatWidgetContent.classList.toggle('quick-chat', isQuickChat && !isWorkspacePickerQuickChat); + this._workspacePicker.refreshPresentation(); if (!isWeb) { - this._workspacePickerVisibleKey.set(!isQuickChat); + this._workspacePickerVisibleKey.set(!isQuickChat || isWorkspacePickerQuickChat); } })); if (!isWeb) { this._register(autorun(reader => { const isQuickChat = this._isQuickChatComposer.read(reader); - const target = isQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.read(reader); + const target = isQuickChat && !isWorkspacePickerQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; if (!target) { return; } @@ -687,7 +703,33 @@ export class NewChatWidget extends Disposable { * Returns the workspace URI for the context picker based on the current workspace selection. */ private _getContextFolderUri(): URI | undefined { - return this._workspacePicker.selectedFolderUri; + return this._isQuickChatComposer.get() ? undefined : this._workspacePicker.selectedFolderUri; + } + + private _selectNoWorkspace(): void { + this._pendingPreferredUpgrade.clear(); + this._newSessionCreation.clear(); + this._openQuickChat(undefined, true); + } + + private _openQuickChat(options?: ICreateNewSessionOptions, keepWorkspacePickerVisible = this._isWorkspacePickerQuickChat.get()): IActiveSession | undefined { + const session = this.sessionsService.openQuickChat(options); + this._workspacePickerQuickChatSessionId.set(keepWorkspacePickerVisible ? session?.sessionId : undefined, undefined); + return session; + } + + private _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.get(); + if (isWeb + || !this._useConsolidatedRemoteWorkspaces.get() + || (!isWorkspacePickerQuickChat && !this.sessionsManagementService.isQuickChatTargetAvailable())) { + return undefined; + } + return { + description: localize('newSessionWorkspacePicker.noWorkspaceDescription', "Start without a backing workspace"), + isSelected: isWorkspacePickerQuickChat, + select: () => this._selectNoWorkspace(), + }; } private _renderWorkspacePicker(container: HTMLElement): IDisposable { @@ -819,8 +861,7 @@ export class NewChatWidget extends Disposable { return false; } const feedbackItems = [...this._feedbackItems.get()]; - const workspaceRoots = session.workspace.get()?.folders.map(folder => folder.root) - ?? (this._workspacePicker.selectedFolderUri ? [this._workspacePicker.selectedFolderUri] : []); + const workspaceRoots = this._getWorkspaceRoots(session); const request = buildNewSessionPrompt(query, feedbackItems, workspaceRoots); const requestContext = new Map(); for (const context of attachedContext ?? []) { @@ -876,7 +917,7 @@ export class NewChatWidget extends Disposable { // session-type/model pickers for the next message. if (background) { if (wasQuickChat) { - this.sessionsService.openQuickChat(); + this._openQuickChat(); } else if (reseedFolderUri) { await this._createNewSession(reseedFolderUri); } @@ -884,6 +925,15 @@ export class NewChatWidget extends Disposable { return true; } + private _getWorkspaceRoots(session: ISession): readonly URI[] { + const sessionWorkspace = session.workspace.get(); + if (sessionWorkspace) { + return sessionWorkspace.folders.map(folder => folder.root); + } + const selectedFolderUri = this._isQuickChatComposer.get() ? undefined : this._workspacePicker.selectedFolderUri; + return selectedFolderUri ? [selectedFolderUri] : []; + } + private _renderFeedbackBanner(container: HTMLElement): void { const host = dom.append(container, dom.$('.session-input-banners.new-session-feedback-banners')); const content = this._register(new MutableDisposable()); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 968174e11f85f3..4c29f38ab44a4c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -95,6 +95,7 @@ export interface IWorkspacePickerOptions { readonly restoreFromSessions?: boolean; readonly sessionWorkspaceProviderFilter?: (providerId: string) => boolean; readonly getWorkspaceGroupAction?: (group: string | undefined) => IWorkspacePickerGroupAction | undefined; + readonly getNoWorkspaceOption?: () => IWorkspacePickerNoWorkspaceOption | undefined; } export interface IWorkspacePickerGroupAction { @@ -105,6 +106,12 @@ export interface IWorkspacePickerGroupAction { readonly hideWorkspaceItems?: boolean; } +export interface IWorkspacePickerNoWorkspaceOption { + readonly description: string; + readonly isSelected: boolean; + readonly select: () => void; +} + export interface IWorkspacePickerTrigger { readonly label?: string; readonly ariaLabel: string; @@ -654,6 +661,10 @@ export class WorkspacePicker extends Disposable { } } + refreshPresentation(): void { + this._updateTriggerLabel(); + } + /** * Subclasses may opt out of the categorical tab bar (e.g. when scoped to * a single host). @@ -1477,7 +1488,32 @@ export class WorkspacePicker extends Disposable { }); } - return items; + const noWorkspaceOption = this._getNoWorkspaceOption(); + if (!noWorkspaceOption || this._directPickerAttachesContext === true) { + return items; + } + + const noWorkspace: IActionListItem = { + kind: ActionListItemKind.Action, + label: localize('workspacePicker.noWorkspace', "No workspace"), + description: noWorkspaceOption.description, + group: { title: '', icon: Codicon.commentDiscussion }, + item: { + checked: noWorkspaceOption.isSelected || undefined, + run: () => { + noWorkspaceOption.select(); + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + }, + }, + }; + return items.length > 0 + ? [noWorkspace, { kind: ActionListItemKind.Separator, label: '' }, ...items] + : [noWorkspace]; + } + + protected _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { + return this.options.getNoWorkspaceOption?.(); } private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void { @@ -1502,8 +1538,9 @@ export class WorkspacePicker extends Disposable { return; } if (options) { - const workspace = this._selectedResolved?.workspace; const reflectsWorkspace = options.reflectsWorkspace === true; + const noWorkspaceSelected = reflectsWorkspace && this._getNoWorkspaceOption()?.isSelected === true; + const workspace = noWorkspaceSelected ? undefined : this._selectedResolved?.workspace; const isSelectedCategory = options.attachesContext !== true && options.group !== undefined && options.group === workspace?.group; @@ -1530,8 +1567,10 @@ export class WorkspacePicker extends Disposable { const hideForMissingGitHubRepository = options.hideWhenNoGitHubRepository === true && this._getCurrentRepositoryId() === undefined; trigger.parentElement?.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); - trigger.classList.toggle('selected', (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); - const icon = (reflectsWorkspace ? workspace?.icon : undefined) + trigger.classList.toggle('selected', noWorkspaceSelected || (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); + const icon = noWorkspaceSelected + ? Codicon.commentDiscussion + : (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); if (!icon || (options.hideIconWhenAttached === true && badgeCount > 0)) { contents.icon?.remove(); @@ -1543,7 +1582,9 @@ export class WorkspacePicker extends Disposable { } contents.icon.className = ThemeIcon.asClassName(icon); } - const label = (reflectsWorkspace ? workspace?.label : undefined) + const label = noWorkspaceSelected + ? localize('workspacePicker.noWorkspace', "No workspace") + : (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); trigger.setAttribute('aria-label', badgeCount > 0 ? localize('workspacePicker.attachedContextCountAriaLabel', "{0}, {1} attached", options.ariaLabel, badgeCount) @@ -1574,13 +1615,18 @@ export class WorkspacePicker extends Disposable { } dom.clearNode(trigger); - const workspace = this._selectedResolved?.workspace; - const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); - const icon = workspace ? workspace.icon : Codicon.project; - - trigger.setAttribute('aria-label', workspace - ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) - : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); + const noWorkspaceSelected = this._getNoWorkspaceOption()?.isSelected === true; + const workspace = noWorkspaceSelected ? undefined : this._selectedResolved?.workspace; + const label = noWorkspaceSelected + ? localize('workspacePicker.noWorkspace', "No workspace") + : workspace?.label ?? localize('pickWorkspace', "workspace"); + const icon = noWorkspaceSelected ? Codicon.commentDiscussion : workspace?.icon ?? Codicon.project; + + trigger.setAttribute('aria-label', noWorkspaceSelected + ? localize('workspacePicker.noWorkspaceSelectedAriaLabel', "New session with no workspace") + : workspace + ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) + : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); contents.icon = dom.append(trigger, renderIcon(icon)); contents.label = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); @@ -1647,7 +1693,7 @@ export class WorkspacePicker extends Disposable { } protected _isSelectedFolder(folderUri: URI | undefined): boolean { - if (!this._selectedFolderUri || !folderUri) { + if (this._getNoWorkspaceOption()?.isSelected || !this._selectedFolderUri || !folderUri) { return false; } return this.uriIdentityService.extUri.isEqual(this._selectedFolderUri, folderUri); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 46980c64880e5c..2ee020e540240d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -42,7 +42,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackComments', "When pull requests have failing checks or unreviewed comments, one banner appears above the input. If several pull requests need attention, use the Previous Banner and Next Banner buttons to move between them. A pull request with both failing checks and comments uses a split button: activate the main action to address both, or use its More Actions button to address only the checks or comments. In-product agent review comments appear as their own carousel item.")); content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); 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. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces.")); + content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces. If quick chats are available, you can also choose No workspace to start a workspace-less chat.")); content.push(localize('sessionsChat.githubContext', "Use Add Context to attach files, images, and, when available, GitHub issues or pull requests.")); content.push(localize('sessionsChat.devContainer', "When Dev Container Agent Host sessions are enabled, Docker is available, and the selected local folder contains a Dev Container configuration, a Dev Container checkbox appears before New Worktree. Select it to run the session on an Agent Host inside that folder's Dev Container.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New 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.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index 6c3f0b6905e144..650379ac503032 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -9,6 +9,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -21,6 +22,7 @@ import { NewChatWidget } from '../../browser/newChatWidget.js'; import { IChatRequestVariableEntry, toFileVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; +import { IWorkspacePickerNoWorkspaceOption } from '../../browser/sessionWorkspacePicker.js'; /** The part of the active session `_recreateOnProviderChange` actually reads. */ interface IActiveDraft { @@ -112,6 +114,7 @@ interface ISendHarness { readonly agentFeedbackService: { removeFeedback(resource: URI, id: string): void }; readonly sessionsManagementService: { sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise }; readonly logService: { error(message: string, ...args: unknown[]): void }; + _getWorkspaceRoots(session: ISession): readonly URI[]; } interface IRenderSessionTypePickerHarness { @@ -131,8 +134,33 @@ interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness _workspacePickerRow: HTMLElement | undefined; } +interface ISelectNoWorkspaceHarness { + readonly _pendingPreferredUpgrade: MutableDisposable; + readonly _newSessionCreation: MutableDisposable; + readonly _isWorkspacePickerQuickChat: IObservable; + readonly _workspacePickerQuickChatSessionId: ReturnType>; + readonly sessionsService: { openQuickChat(): { readonly sessionId: string } }; + _openQuickChat(options?: undefined, keepWorkspacePickerVisible?: boolean): { readonly sessionId: string } | undefined; +} + +interface INoWorkspaceOptionHarness { + readonly _useConsolidatedRemoteWorkspaces: IObservable; + readonly _isWorkspacePickerQuickChat: IObservable; + readonly sessionsManagementService: { isQuickChatTargetAvailable(): boolean }; + _selectNoWorkspace(): void; +} + +interface IWorkspaceRootsHarness { + readonly _isQuickChatComposer: IObservable; + readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined }; +} + const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; const renderSessionTypePicker = Reflect.get(NewChatWidget.prototype, '_renderSessionTypePicker') as (this: IRenderSessionTypePickerHarness, container: HTMLElement, isQuickChat: boolean) => void; +const selectNoWorkspace = Reflect.get(NewChatWidget.prototype, '_selectNoWorkspace') as (this: ISelectNoWorkspaceHarness) => void; +const openQuickChat = Reflect.get(NewChatWidget.prototype, '_openQuickChat') as ISelectNoWorkspaceHarness['_openQuickChat']; +const getNoWorkspaceOption = Reflect.get(NewChatWidget.prototype, '_getNoWorkspaceOption') as (this: INoWorkspaceOptionHarness) => IWorkspacePickerNoWorkspaceOption | undefined; +const getWorkspaceRoots = Reflect.get(NewChatWidget.prototype, '_getWorkspaceRoots') as (this: IWorkspaceRootsHarness, session: ISession) => readonly URI[]; function createHarness( pendingPreferredUpgrade: MutableDisposable, @@ -261,6 +289,107 @@ suite('NewChatWidget', () => { }); }); + test('selecting No workspace cancels pending workspace creation', () => { + let pendingUpgradeDisposed = false; + let sessionCreationDisposed = false; + let quickChatOpenCount = 0; + const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); + const newSessionCreation = disposables.add(new MutableDisposable()); + const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', undefined); + pendingPreferredUpgrade.value = toDisposable(() => pendingUpgradeDisposed = true); + newSessionCreation.value = toDisposable(() => sessionCreationDisposed = true); + + const harness: ISelectNoWorkspaceHarness = { + _pendingPreferredUpgrade: pendingPreferredUpgrade, + _newSessionCreation: newSessionCreation, + _isWorkspacePickerQuickChat: constObservable(false), + _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, + sessionsService: { + openQuickChat: () => { + quickChatOpenCount++; + return { sessionId: 'quick-chat' }; + }, + }, + _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + }; + selectNoWorkspace.call(harness); + + assert.deepStrictEqual({ + pendingUpgradeDisposed, + sessionCreationDisposed, + quickChatOpenCount, + workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId.get(), + }, { + pendingUpgradeDisposed: true, + sessionCreationDisposed: true, + quickChatOpenCount: 1, + workspacePickerQuickChatSessionId: 'quick-chat', + }); + }); + + test('ordinary quick chats do not retain the workspace picker', () => { + const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', 'previous-quick-chat'); + const harness: ISelectNoWorkspaceHarness = { + _pendingPreferredUpgrade: disposables.add(new MutableDisposable()), + _newSessionCreation: disposables.add(new MutableDisposable()), + _isWorkspacePickerQuickChat: constObservable(false), + _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, + sessionsService: { openQuickChat: () => ({ sessionId: 'ordinary-quick-chat' }) }, + _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + }; + + openQuickChat.call(harness); + + assert.strictEqual(workspacePickerQuickChatSessionId.get(), undefined); + }); + + test('offers No workspace only when enabled and quick chats are available', () => { + const cases = [ + { enabled: false, available: true, isWorkspacePickerQuickChat: false }, + { enabled: true, available: false, isWorkspacePickerQuickChat: false }, + { enabled: true, available: true, isWorkspacePickerQuickChat: false }, + { enabled: true, available: false, isWorkspacePickerQuickChat: true }, + ]; + + const options = cases.map(testCase => { + const option = getNoWorkspaceOption.call({ + _useConsolidatedRemoteWorkspaces: constObservable(testCase.enabled), + _isWorkspacePickerQuickChat: constObservable(testCase.isWorkspacePickerQuickChat), + sessionsManagementService: { isQuickChatTargetAvailable: () => testCase.available }, + _selectNoWorkspace: () => { }, + }); + return option && { description: option.description, isSelected: option.isSelected }; + }); + + assert.deepStrictEqual(options, isWeb + ? [undefined, undefined, undefined, undefined] + : [ + undefined, + undefined, + { description: 'Start without a backing workspace', isSelected: false }, + { description: 'Start without a backing workspace', isSelected: true }, + ]); + }); + + test('workspace-less chats do not inherit the previous picker workspace', () => { + const staleFolder = URI.file('/previous-workspace'); + const session = upcastPartial({ workspace: constObservable(undefined) }); + + assert.deepStrictEqual({ + quickChat: getWorkspaceRoots.call({ + _isQuickChatComposer: constObservable(true), + _workspacePicker: { selectedFolderUri: staleFolder }, + }, session).map(uri => uri.toString()), + workspaceDraft: getWorkspaceRoots.call({ + _isQuickChatComposer: constObservable(false), + _workspacePicker: { selectedFolderUri: staleFolder }, + }, session).map(uri => uri.toString()), + }, { + quickChat: [], + workspaceDraft: [staleFolder.toString()], + }); + }); + test('replays a provider change that arrives while creating the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); @@ -500,6 +629,7 @@ suite('NewChatWidget', () => { }, }, logService: { error: () => { } }, + _getWorkspaceRoots: () => [primaryFolder], }, 'work across contexts', [ composerAttachment, duplicateRepositoryAttachment, @@ -549,6 +679,7 @@ suite('NewChatWidget', () => { }, }, logService: { error: () => { } }, + _getWorkspaceRoots: () => [], }, 'work across contexts'); assert.deepStrictEqual({ result, pickerOpenCount, sendCount }, { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index e8649159a643a4..c682f75aec1612 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -3164,6 +3164,65 @@ suite('WorkspacePicker - Tab discovery', () => { }); }); + test('selects No workspace through the consolidated picker', async () => { + let noWorkspaceSelected = false; + const picker = createTestablePicker(disposables, providersService, true, { + getNoWorkspaceOption: () => ({ + description: 'Start without a backing workspace', + isSelected: noWorkspaceSelected, + select: () => noWorkspaceSelected = true, + }), + }, undefined, undefined, true); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ + label: 'Workspace', + ariaLabel: 'Choose a workspace for the new session', + icon: Codicon.project, + reflectsWorkspace: true, + }]); + + const before = { + items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ + label: item.label, + description: item.description, + checked: item.item?.checked, + })), + triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + }; + await picker.select('No workspace'); + + assert.deepStrictEqual({ + before, + after: { + items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ + label: item.label, + description: item.description, + checked: item.item?.checked, + })), + triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + triggerAriaLabel: container.querySelector('.action-label')?.getAttribute('aria-label'), + }, + }, { + before: { + items: [{ + label: 'No workspace', + description: 'Start without a backing workspace', + checked: undefined, + }], + triggerLabel: 'Workspace', + }, + after: { + items: [{ + label: 'No workspace', + description: 'Start without a backing workspace', + checked: true, + }], + triggerLabel: 'No workspace', + triggerAriaLabel: 'Workspace: No workspace', + }, + }); + }); + test('keeps GitHub context actions separate when groups are combined', () => { providersService.setProviders([ createMockProvider('github', { diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index fb3af23807fddf..6c52a23563053f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -465,7 +465,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', default: false, scope: ConfigurationScope.APPLICATION, - description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available."), + description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available and, when supported, a No workspace option."), tags: ['experimental'], experiment: { mode: 'auto' }, }, From cb18f03d5a04d86da42dfef754d2d2af38acdac5 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 3 Sep 2026 17:18:19 -0400 Subject: [PATCH 39/44] chat: Select primary Luna model for dictation cleanup (#334363) * chat: Select primary dictation cleanup models Resolve the configured Luna and Nano cleanup choices directly through their Copilot model IDs instead of utility aliases. Log model selection and successful application so the cleanup path can be verified without recording transcript content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: Remove unavailable Nano cleanup option Keep the experimental cleanup model setting limited to the available Luna primary model and the utility fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vscode-node/languageModelAccess.ts | 14 +++++++++-- .../test/languageModelAccess.test.ts | 13 ++++++---- .../chat/browser/chat.shared.contribution.ts | 2 +- .../speechToText/chatSpeechToTextService.ts | 24 ++++++++----------- .../browser/chatSpeechToTextService.test.ts | 12 ++-------- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index fb90f002fb8a3f..1e1f6017310226 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -158,6 +158,7 @@ function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boo const DICTATION_CLEANUP_NANO_ALIAS = 'copilot-dictation-cleanup-nano'; const DICTATION_CLEANUP_LUNA_ALIAS = 'copilot-dictation-cleanup-luna'; +const DICTATION_CLEANUP_LUNA_MODEL_ID = 'gpt-5.6-luna'; const dictationCleanupAliases: ReadonlySet = new Set([DICTATION_CLEANUP_NANO_ALIAS, DICTATION_CLEANUP_LUNA_ALIAS]); const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility', DICTATION_CLEANUP_NANO_ALIAS, DICTATION_CLEANUP_LUNA_ALIAS]; @@ -175,7 +176,7 @@ const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-sm * normal copilot model entry. */ export function buildUtilityAliasModelInfo( - family: ChatEndpointFamily, + family: string, endpoint: IChatEndpoint, models: readonly vscode.LanguageModelChatInformation[], baseCount: number, @@ -453,6 +454,14 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } } + const luna = this._resolvedUtilityEndpoints.get(DICTATION_CLEANUP_LUNA_ALIAS); + if (luna && !models.some(model => model.id === DICTATION_CLEANUP_LUNA_MODEL_ID)) { + this._utilityAliasEndpoints.set(DICTATION_CLEANUP_LUNA_MODEL_ID, luna.endpoint); + const modelInfo = buildUtilityAliasModelInfo(DICTATION_CLEANUP_LUNA_MODEL_ID, luna.endpoint, models, luna.baseCount, requiresAuthorization); + this._logService.trace(`[LanguageModelAccess] Publishing core-only model '${DICTATION_CLEANUP_LUNA_MODEL_ID}' -> ${luna.endpoint.model}.`); + models.push(modelInfo.info); + } + // Resolution may hang (override lookups, base-count tokenization), so keep it off // the model-info request path. Newly resolved endpoints are published on the next // request once `_refreshUtilityOverrides` fires `_onDidChange`. @@ -546,7 +555,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib progress: vscode.Progress, token: vscode.CancellationToken ): Promise { - if (dictationCleanupAliases.has(model.id) && options.requestInitiator !== 'core') { + const isCoreOnlyModel = dictationCleanupAliases.has(model.id) || (model.id === DICTATION_CLEANUP_LUNA_MODEL_ID && this._utilityAliasEndpoints.has(model.id)); + if (isCoreOnlyModel && options.requestInitiator !== 'core') { throw new Error(`Model ${model.id} is only available to VS Code core.`); } let endpoint = await this._getEndpointForModel(model, buildAutoRoutingContext(messages, options)); 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 d658ae9b73f378..ab949fd2deb7bf 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 @@ -437,7 +437,7 @@ suite('LanguageModelAccess model info', () => { } }); - test('publishes core-only aliases for dictation cleanup without publishing hidden models directly', async () => { + test('publishes core-only dictation cleanup models without exposing them in the picker', async () => { const makeHiddenEndpoint = (model: string): IChatEndpoint => ({ model, name: model, @@ -510,13 +510,15 @@ suite('LanguageModelAccess model info', () => { assert.ok(modelInfo, 'provideLanguageModelChatInfo did not resolve'); const nanoAlias = modelInfo.find(m => m.id === 'copilot-dictation-cleanup-nano'); const lunaAlias = modelInfo.find(m => m.id === 'copilot-dictation-cleanup-luna'); + const lunaModel = modelInfo.find(m => m.id === 'gpt-5.6-luna'); assert.deepStrictEqual({ nanoAliasPublished: Boolean(nanoAlias), nanoAliasUserSelectable: nanoAlias?.isUserSelectable, lunaAliasPublished: Boolean(lunaAlias), lunaAliasUserSelectable: lunaAlias?.isUserSelectable, nanoPublishedDirectly: modelInfo.some(m => m.id === 'gpt-5.4-nano'), - lunaPublishedDirectly: modelInfo.some(m => m.id === 'gpt-5.6-luna'), + lunaPublishedDirectly: Boolean(lunaModel), + lunaDirectlyUserSelectable: lunaModel?.isUserSelectable, otherPublished: modelInfo.some(m => m.id === 'some-hidden-model'), }, { nanoAliasPublished: true, @@ -524,13 +526,14 @@ suite('LanguageModelAccess model info', () => { lunaAliasPublished: true, lunaAliasUserSelectable: false, nanoPublishedDirectly: false, - lunaPublishedDirectly: false, + lunaPublishedDirectly: true, + lunaDirectlyUserSelectable: false, otherPublished: false, }); - for (const alias of [nanoAlias!, lunaAlias!]) { + for (const model of [nanoAlias!, lunaAlias!, lunaModel!]) { await assert.rejects( testAccess._provideLanguageModelChatResponse( - alias, + model, [], { requestInitiator: 'publisher.extension' } as vscode.ProvideLanguageModelChatResponseOptions, { report: () => { } }, diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 6c52a23563053f..4e390359e09dfa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -374,7 +374,7 @@ configurationRegistry.registerConfiguration({ }, 'dictation.experimental.llmCleanupModel': { type: 'string', - enum: ['auto', 'copilot-utility-small', 'gpt-5.4-nano', 'gpt-5.6-luna'], + enum: ['auto', 'copilot-utility-small', 'gpt-5.6-luna'], markdownDescription: nls.localize('dictation.experimental.llmCleanupModel', "Controls the language model used for experimental dictation cleanup. `auto` follows the experiment-provided default."), default: 'auto', tags: ['experimental'], diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index 802f985b171ffe..da0201a7e87c79 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -141,12 +141,10 @@ const LLM_CLEANUP_TIMEOUT_MS = 5000; const LLM_CLEANUP_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-utility-small' } as const; const LLM_CLEANUP_MODEL_SETTING = 'dictation.experimental.llmCleanupModel'; -const LLM_CLEANUP_NANO_MODEL_ID = 'gpt-5.4-nano'; -const LLM_CLEANUP_NANO_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' } as const; const LLM_CLEANUP_LUNA_MODEL_ID = 'gpt-5.6-luna'; -const LLM_CLEANUP_LUNA_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' } as const; +const LLM_CLEANUP_LUNA_MODEL_SELECTOR = { vendor: 'copilot', id: LLM_CLEANUP_LUNA_MODEL_ID } as const; -type DictationCleanupModel = 'none' | 'copilot-utility-small' | 'gpt-5.4-nano' | 'gpt-5.6-luna'; +type DictationCleanupModel = 'none' | 'copilot-utility-small' | 'gpt-5.6-luna'; /** * Which backend transcribes dictation audio: @@ -603,12 +601,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _getLlmCleanupModel(): Exclude { const configuredModel = this._configurationService.getValue(LLM_CLEANUP_MODEL_SETTING); - if (configuredModel === LLM_CLEANUP_NANO_MODEL_ID || configuredModel === LLM_CLEANUP_LUNA_MODEL_ID || configuredModel === LLM_CLEANUP_MODEL_SELECTOR.id) { + if (configuredModel === LLM_CLEANUP_LUNA_MODEL_ID || configuredModel === LLM_CLEANUP_MODEL_SELECTOR.id) { return configuredModel; } const experimentDefault = this._configurationService.inspect(LLM_CLEANUP_MODEL_SETTING).defaultValue; switch (experimentDefault) { - case LLM_CLEANUP_NANO_MODEL_ID: case LLM_CLEANUP_LUNA_MODEL_ID: return experimentDefault; default: @@ -1377,11 +1374,10 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo }, LLM_CLEANUP_TIMEOUT_MS); try { const cleanupModel = this._getLlmCleanupModel(); - const modelSelector = cleanupModel === LLM_CLEANUP_NANO_MODEL_ID - ? LLM_CLEANUP_NANO_MODEL_SELECTOR - : cleanupModel === LLM_CLEANUP_LUNA_MODEL_ID - ? LLM_CLEANUP_LUNA_MODEL_SELECTOR - : LLM_CLEANUP_MODEL_SELECTOR; + const modelSelector = cleanupModel === LLM_CLEANUP_LUNA_MODEL_ID + ? LLM_CLEANUP_LUNA_MODEL_SELECTOR + : LLM_CLEANUP_MODEL_SELECTOR; + this._logService.info(`[chat-stt] selecting language model cleanup model (vendor=${modelSelector.vendor}, id=${modelSelector.id})`); let models = await raceCancellation( this._languageModelsService.selectLanguageModels(modelSelector), cts.token, @@ -1409,7 +1405,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._logService.info(`[chat-stt] skipped language model cleanup (reason=noModel, phase=${phase}, elapsedMs=${Date.now() - cleanupStartMs}); using raw transcript`); return undefined; } - this._logService.trace(`[chat-stt] language model cleanup selected model (elapsedMs=${Date.now() - cleanupStartMs}, modelCount=${models.length})`); + this._logService.info(`[chat-stt] selected language model cleanup model (id=${selectedCleanupModel}, elapsedMs=${Date.now() - cleanupStartMs}, modelCount=${models.length})`); phase = 'loadInstructions'; const dictationInstructions = await raceCancellation( @@ -1433,7 +1429,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._sessionCleanupModel = selectedCleanupModel; phase = 'startRequest'; this._logService.trace(`[chat-stt] language model cleanup sending request (elapsedMs=${Date.now() - cleanupStartMs})`); - const requestOptions = selectedCleanupModel === LLM_CLEANUP_NANO_MODEL_ID || selectedCleanupModel === LLM_CLEANUP_LUNA_MODEL_ID + const requestOptions = selectedCleanupModel === LLM_CLEANUP_LUNA_MODEL_ID ? { configuration: { reasoningEffort: 'none' } } : {}; const response = await raceCancellation( @@ -1495,7 +1491,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._logService.warn(`[chat-stt] language model cleanup returned refusal-like output (rawChars=${text.length}, cleanedChars=${cleaned.length}); using raw transcript`); return undefined; } - this._logService.info(`[chat-stt] applied language model cleanup (rawChars=${text.length}, cleanedChars=${cleaned.length}, elapsedMs=${Date.now() - cleanupStartMs}, firstTextMs=${firstTextMs ?? -1})`); + this._logService.info(`[chat-stt] applied language model cleanup (model=${selectedCleanupModel}, rawChars=${text.length}, cleanedChars=${cleaned.length}, elapsedMs=${Date.now() - cleanupStartMs}, firstTextMs=${firstTextMs ?? -1})`); return cleaned; } catch (err) { const reason = timedOut ? 'timeout' : cts.token.isCancellationRequested ? 'cancelled' : 'error'; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index d2ad83916cca28..378451647cfb95 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -742,23 +742,17 @@ suite('ChatSpeechToTextService', () => { }; await createService()._cleanupWithLanguageModel('control transcript', CancellationToken.None); - await createService('auto', 'gpt-5.4-nano')._cleanupWithLanguageModel('Nano experiment transcript', CancellationToken.None); await createService('auto', 'gpt-5.6-luna')._cleanupWithLanguageModel('Luna experiment transcript', CancellationToken.None); await createService('auto', 'unexpected-model')._cleanupWithLanguageModel('unknown experiment transcript', CancellationToken.None); - await createService('gpt-5.4-nano')._cleanupWithLanguageModel('configured Nano transcript', CancellationToken.None); await createService('gpt-5.6-luna')._cleanupWithLanguageModel('configured Luna transcript', CancellationToken.None); await createService('copilot-utility-small', 'gpt-5.6-luna')._cleanupWithLanguageModel('configured utility transcript', CancellationToken.None); assert.deepStrictEqual(selectors, [ { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' }, + { vendor: 'copilot', id: 'gpt-5.6-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' }, - { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' }, + { vendor: 'copilot', id: 'gpt-5.6-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, { vendor: 'copilot', id: 'copilot-utility-small' }, ]); @@ -795,7 +789,6 @@ suite('ChatSpeechToTextService', () => { return service; }; - await createService('gpt-5.4-nano')._cleanupWithLanguageModel('Nano transcript', CancellationToken.None); await createService('gpt-5.6-luna')._cleanupWithLanguageModel('Luna transcript', CancellationToken.None); const fallbackService = createService('gpt-5.6-luna'); let selectionCall = 0; @@ -803,7 +796,6 @@ suite('ChatSpeechToTextService', () => { await fallbackService._cleanupWithLanguageModel('utility fallback transcript', CancellationToken.None); assert.deepStrictEqual(requestConfigurations, [ - { reasoningEffort: 'none' }, { reasoningEffort: 'none' }, undefined, ]); From 74a1af8aaea7912cefbb9614462663ee71df94f6 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 3 Sep 2026 17:18:44 -0400 Subject: [PATCH 40/44] Experimental model picker redesign (#334332) * Add experimental tabbed model picker Redesigns the chat model picker behind `chat.experimentalModelPicker` (off by default) so the new and old designs can be compared side by side. The separate model, context, and thinking effort pickers become one surface: a tabbed list with a detail card that shows thinking effort, context, and pricing that updates as those change. Destinations are capped at two, built-in and user-provided, since almost no one has more than one BYOK provider. Sections are Pinned, a short suggested list, and the rest folded away. Also adds a shared Switch widget and a segmented control, both reused by the picker and the customizations UI, and support in the action list for tab bar actions, icon-only tabs, footers, welcome bodies, and in-place item updates. Includes an unrelated fix for the DOM sanitizer, which now replaces its Trusted Types policy when the realm that created it goes away. * Fix some small issues with the new model picker * Model picker * Review feedback * Fix Monaco editor build for the sanitizer policy replacement The stale-policy replacement was cast to the global `TrustedTypePolicy`, but dompurify's config types that property with the `TrustedTypePolicy` imported from `trusted-types/lib/index.js`. The two normally unify, so the regular build accepts it. The editor tree shaker compiles against a synthetic root where `trusted-types` resolves through both a relative and an absolute path, leaving two declarations whose private `brand` makes them nominally incompatible, and `editor-distro` failed to compile. Name the property's own type instead, so only one declaration is involved however `trusted-types` resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address feedback and fix tests * Fix provider icons * Fix ResizeObserver loop in the model picker chip `_renderLabel` runs from a resize-driven autorun. Measuring the chip there cleared `minWidth`, read `scrollWidth`, then wrote `minWidth` again, so every ResizeObserver pass dirtied layout twice and never settled. The NewSessionCompactAutoModel fixture failed to render with "ResizeObserver loop completed with undelivered notifications". Take the fixed widths from main instead, including the narrower floor for the Auto label that #334128 added alongside that fixture. This resolves the merge conflict in this hunk the other way; the measured floor read better in the abstract, but it cannot be computed from inside the resize callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore MCP server switch styling The plugin list moved to the shared `Switch` widget and the hand-rolled `.plugin-enable-switch` CSS was deleted with it, but the MCP server list still built that markup by hand. With no rules left to match, its switches rendered as bare unstyled buttons: the McpServersTab fixture lost the filled pill and its thumb entirely. Move the MCP list onto `Switch` too, so both lists share one control. This is what the screenshot diff on this PR was reporting; accepting those hashes would have pinned the unstyled rendering as the baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/domSanitize.ts | 57 +- src/vs/base/browser/ui/radio/radio.css | 46 ++ src/vs/base/browser/ui/radio/radio.ts | 90 ++- src/vs/base/browser/ui/toggle/switch.css | 60 ++ src/vs/base/browser/ui/toggle/switch.ts | 99 +++ src/vs/base/test/browser/domSanitize.test.ts | 53 +- .../actionWidget/browser/actionList.ts | 203 ++++-- .../actionWidget/browser/actionWidget.css | 52 -- .../browser/tabbedActionListWidget.css | 34 + .../browser/tabbedActionListWidget.ts | 188 ++++- .../test/browser/actionList.test.ts | 99 ++- .../browser/tabbedActionListWidget.test.ts | 41 ++ .../browser/permissionPickerList.fixture.ts | 2 +- .../browser/aiCustomization/mcpListWidget.ts | 24 +- .../media/aiCustomizationManagement.css | 52 +- .../aiCustomization/pluginListWidget.ts | 18 +- .../chat/browser/chat.shared.contribution.ts | 6 + .../input/chatModelConfigurationStore.ts | 2 +- .../input/modelPicker/media/modelPicker.css | 679 +++++++++++++++++- .../modelPicker/modelPickerActionItem.ts | 21 +- .../input/modelPicker/modelPickerAutoRow.ts | 113 +++ .../input/modelPicker/modelPickerBadges.ts | 63 ++ .../input/modelPicker/modelPickerCard.ts | 320 +++++++++ .../modelPicker/modelPickerConfiguration.ts | 76 +- .../input/modelPicker/modelPickerDetails.ts | 54 ++ .../input/modelPicker/modelPickerHover.ts | 63 +- .../modelPicker/modelPickerItemPrimitives.ts | 2 +- .../input/modelPicker/modelPickerItems.ts | 4 + .../input/modelPicker/modelPickerLineage.ts | 78 ++ .../modelPicker/modelPickerModelConfig.ts | 104 +++ .../modelPicker/modelPickerPresentation.ts | 22 + .../modelPicker/modelPickerTabbedWidget.ts | 453 ++++++++++++ .../input/modelPicker/modelPickerTabs.ts | 309 ++++++++ .../input/modelPicker/modelPickerTelemetry.ts | 73 ++ .../input/modelPicker/modelPickerVariants.ts | 73 ++ .../input/modelPicker/modelPickerWelcome.ts | 39 + .../input/modelPicker/modelPickerWidget.ts | 136 +++- .../input/modelPicker/modelProviderIcons.ts | 46 +- .../contrib/chat/common/languageModels.ts | 10 +- .../modelPicker/modelPickerAutoRow.test.ts | 110 +++ .../modelPickerConfiguration.test.ts | 2 +- .../input/modelPicker/modelPickerTabs.test.ts | 660 +++++++++++++++++ .../sessionTargetPickerActionItem.test.ts | 5 +- .../chat/tabbedModelPicker.fixture.ts | 503 +++++++++++++ 44 files changed, 4751 insertions(+), 393 deletions(-) create mode 100644 src/vs/base/browser/ui/toggle/switch.css create mode 100644 src/vs/base/browser/ui/toggle/switch.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts diff --git a/src/vs/base/browser/domSanitize.ts b/src/vs/base/browser/domSanitize.ts index 942bf6e8315e34..027fdee5c73d1b 100644 --- a/src/vs/base/browser/domSanitize.ts +++ b/src/vs/base/browser/domSanitize.ts @@ -5,6 +5,7 @@ import { Schemas } from '../common/network.js'; import { reset } from './dom.js'; +import { createTrustedTypesPolicy } from './trustedTypes.js'; // eslint-disable-next-line no-restricted-imports import dompurify, * as DomPurifyTypes from './dompurify/dompurify.js'; @@ -322,21 +323,61 @@ function doSanitizeHtml(untrusted: string, config: DomSanitizerConfig | undefine } if (outputType === 'dom') { - return dompurify.sanitize(untrusted, { - ...resolvedConfig, - RETURN_DOM_FRAGMENT: true - }); + return sanitizeSurvivingStalePolicy(untrusted, { ...resolvedConfig, RETURN_DOM_FRAGMENT: true }) as DocumentFragment; } else { - return dompurify.sanitize(untrusted, { - ...resolvedConfig, - RETURN_TRUSTED_TYPE: true - }) as unknown as TrustedHTML; // Cast from lib TrustedHTML to global TrustedHTML + return sanitizeSurvivingStalePolicy(untrusted, { ...resolvedConfig, RETURN_TRUSTED_TYPE: true }) as unknown as TrustedHTML; // Cast from lib TrustedHTML to global TrustedHTML } } finally { dompurify.removeAllHooks(); } } +/** Names a replacement policy; Trusted Types rejects a name that is already taken. */ +let stalePolicyReplacementCount = 0; + +/** The sanitizer call this module recovers around. */ +type SanitizeCall = (untrusted: string, config: DomPurifyTypes.Config) => ReturnType; + +/** + * Sanitizes HTML, replacing the sanitizer's Trusted Types policy first when the policy's + * creating realm is gone and every call would otherwise throw. The replacement is kept + * for later calls, so this recovers once rather than on every call. + * + * Exported, with `sanitize` injectable, only so a test can drive the recovery: dompurify + * caches one policy for the lifetime of the module, so once anything has sanitized, no + * later stand-in policy is ever consulted. Prefer {@link sanitizeHtml}. + */ +export function sanitizeSurvivingStalePolicy( + untrusted: string, + config: DomPurifyTypes.Config, + sanitize: SanitizeCall = (html, cfg) => dompurify.sanitize(html, cfg), +): string | DocumentFragment | TrustedHTML { + try { + return sanitize(untrusted, config); + } catch (error) { + if (!isStaleTrustedTypesPolicy(error)) { + throw error; + } + const replacement = createTrustedTypesPolicy(`domSanitize${stalePolicyReplacementCount++}`, { + createHTML: (value: string) => value, + createScriptURL: (value: string) => value, + }); + if (!replacement) { + throw error; + } + // Named through dompurify's own config rather than the global `TrustedTypePolicy`. + // The two spell the same type, but the editor build resolves `trusted-types` twice + // and the branded declarations then do not unify. + const policy = replacement as unknown as DomPurifyTypes.Config['TRUSTED_TYPES_POLICY']; + return sanitize(untrusted, { ...config, TRUSTED_TYPES_POLICY: policy }); + } +} + +/** Whether the failure is a Trusted Types policy whose realm is gone, rather than bad markup. */ +function isStaleTrustedTypesPolicy(error: unknown): boolean { + return error instanceof Error && /no longer runnable/i.test(error.message); +} + const selfClosingTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; const replaceWithPlainTextHook: DomPurifyTypes.UponSanitizeElementHook = (node, data, _config) => { diff --git a/src/vs/base/browser/ui/radio/radio.css b/src/vs/base/browser/ui/radio/radio.css index 259c157011f360..9f89d689bd75e7 100644 --- a/src/vs/base/browser/ui/radio/radio.css +++ b/src/vs/base/browser/ui/radio/radio.css @@ -67,3 +67,49 @@ .monaco-custom-radio > .monaco-button:hover:not(.active) { background-color: var(--vscode-radio-inactiveHoverBackground); } + +/* optional segmented appearance - opt in via IRadioOptions.className */ + +.monaco-custom-radio.segmented { + align-items: center; + gap: var(--vscode-spacing-size20); + padding: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-circle); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.monaco-custom-radio.segmented > .monaco-button { + flex: 1 1 0; + justify-content: center; + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border: none; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: transparent; + font-size: var(--vscode-fontSize-label2); + white-space: nowrap; +} + +.monaco-custom-radio.segmented > .monaco-button:hover:not(.active) { + color: var(--vscode-foreground); + background: transparent; +} + +.monaco-custom-radio.segmented > .monaco-button.active, +.monaco-custom-radio.segmented > .monaco-button.active:hover { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); + box-shadow: var(--vscode-shadow-sm); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.monaco-custom-radio.segmented > .monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.hc-black .monaco-custom-radio.segmented > .monaco-button.active, +.hc-light .monaco-custom-radio.segmented > .monaco-button.active { + border: var(--vscode-strokeThickness) solid var(--vscode-radio-activeBorder, var(--vscode-contrastActiveBorder)); +} diff --git a/src/vs/base/browser/ui/radio/radio.ts b/src/vs/base/browser/ui/radio/radio.ts index 163a5869ca4c6b..8fb75fffbdbdf5 100644 --- a/src/vs/base/browser/ui/radio/radio.ts +++ b/src/vs/base/browser/ui/radio/radio.ts @@ -7,7 +7,9 @@ import { Widget } from '../widget.js'; import { ThemeIcon } from '../../../common/themables.js'; import { Emitter } from '../../../common/event.js'; import './radio.css'; -import { $ } from '../../dom.js'; +import { $, addDisposableListener, EventHelper, EventType } from '../../dom.js'; +import { StandardKeyboardEvent } from '../../keyboardEvent.js'; +import { KeyCode } from '../../../common/keyCodes.js'; import { IHoverDelegate } from '../hover/hoverDelegate.js'; import { Button } from '../button/button.js'; import { DisposableMap, DisposableStore } from '../../../common/lifecycle.js'; @@ -26,6 +28,8 @@ export interface IRadioStyles { export interface IRadioOptionItem { readonly text: string; readonly tooltip?: string; + /** Accessible name. Defaults to {@link tooltip} ?? {@link text}. Set it when {@link text} is icon-only. */ + readonly ariaLabel?: string; readonly isActive?: boolean; readonly disabled?: boolean; } @@ -34,6 +38,16 @@ export interface IRadioOptions { readonly items: ReadonlyArray; readonly activeIcon?: ThemeIcon; readonly hoverDelegate?: IHoverDelegate; + /** Accessible name of the radio group. */ + readonly ariaLabel?: string; + /** Extra class added to {@link Radio.domNode}, e.g. `segmented` for the pill appearance. */ + readonly className?: string; + /** + * How arrow keys behave. `select` (default) moves focus and selects, matching the + * ARIA radiogroup pattern. `focus` only moves focus, and Enter or Space selects — + * use it when selecting has a side effect the user should be able to travel past. + */ + readonly arrowKeyBehavior?: 'select' | 'focus'; } export class Radio extends Widget { @@ -44,9 +58,11 @@ export class Radio extends Widget { readonly domNode: HTMLElement; private readonly hoverDelegate: IHoverDelegate; + private readonly arrowKeyBehavior: 'select' | 'focus'; private items: ReadonlyArray = []; private activeItem: IRadioOptionItem | undefined; + private orderedButtons: Button[] = []; private readonly buttons = this._register(new DisposableMap()); @@ -54,15 +70,23 @@ export class Radio extends Widget { super(); this.hoverDelegate = opts.hoverDelegate ?? this._register(createInstantHoverDelegate()); + this.arrowKeyBehavior = opts.arrowKeyBehavior ?? 'select'; this.domNode = $('.monaco-custom-radio'); - this.domNode.setAttribute('role', 'radio'); + if (opts.className) { + this.domNode.classList.add(opts.className); + } + this.domNode.setAttribute('role', 'radiogroup'); + if (opts.ariaLabel) { + this.domNode.setAttribute('aria-label', opts.ariaLabel); + } this.setItems(opts.items); } setItems(items: ReadonlyArray): void { this.buttons.clearAndDisposeAll(); + this.orderedButtons = []; this.items = items; this.activeItem = this.items.find(item => item.isActive) ?? this.items[0]; for (let index = 0; index < this.items.length; index++) { @@ -71,16 +95,24 @@ export class Radio extends Widget { const button = disposables.add(new Button(this.domNode, { hoverDelegate: this.hoverDelegate, title: item.tooltip, + ariaLabel: item.ariaLabel, supportIcons: true, })); + button.element.setAttribute('role', 'radio'); button.enabled = !item.disabled; - disposables.add(button.onDidClick(() => { - if (this.activeItem !== item) { - this.activeItem = item; - this.updateButtons(); - this._onDidSelect.fire(index); + // Button turns Enter and Space into a click, which is how `focus` mode selects. + disposables.add(button.onDidClick(() => this.selectItem(index))); + disposables.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const delta = event.equals(KeyCode.RightArrow) || event.equals(KeyCode.DownArrow) ? 1 + : event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.UpArrow) ? -1 : 0; + if (delta === 0) { + return; } + EventHelper.stop(e, true); + this.navigate(index, delta); })); + this.orderedButtons.push(button); this.buttons.set(button, { item, dispose: () => disposables.dispose() }); } this.updateButtons(); @@ -98,6 +130,48 @@ export class Radio extends Widget { for (const [button] of this.buttons) { button.enabled = enabled; } + this.updateButtons(); + } + + /** Moves focus to the active item, for callers that rebuild the row after a selection. */ + focusActiveItem(): void { + const index = this.activeItem ? this.items.indexOf(this.activeItem) : -1; + if (index !== -1) { + this.orderedButtons[index]?.focus(); + } + } + + private selectItem(index: number): void { + const item = this.items[index]; + if (!item || this.activeItem === item) { + return; + } + this.activeItem = item; + this.updateButtons(); + this._onDidSelect.fire(index); + } + + /** Moves to the next enabled item in `delta` direction, wrapping around the ends. */ + private navigate(from: number, delta: number): void { + const count = this.items.length; + for (let offset = 1; offset <= count; offset++) { + const index = (((from + delta * offset) % count) + count) % count; + if (this.items[index].disabled) { + continue; + } + if (this.arrowKeyBehavior === 'select') { + this.selectItem(index); + } + this.focusItem(index); + return; + } + } + + private focusItem(index: number): void { + for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) { + this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1; + } + this.orderedButtons[index]?.focus(); } private updateButtons(): void { @@ -107,6 +181,8 @@ export class Radio extends Widget { isActive = item === this.activeItem; button.element.classList.toggle('active', isActive); button.element.classList.toggle('previous-active', isPreviousActive); + button.element.setAttribute('aria-checked', String(isActive)); + button.element.tabIndex = isActive ? 0 : -1; button.label = item.text; } } diff --git a/src/vs/base/browser/ui/toggle/switch.css b/src/vs/base/browser/ui/toggle/switch.css new file mode 100644 index 00000000000000..9dea7703a3cd4c --- /dev/null +++ b/src/vs/base/browser/ui/toggle/switch.css @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-switch { + position: relative; + flex: 0 0 auto; + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size160); + padding: 0; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-circle); + background: color-mix(in srgb, var(--vscode-descriptionForeground) 36%, transparent); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.monaco-switch.checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.monaco-switch:disabled { + cursor: default; + opacity: 0.5; +} + +.monaco-switch:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.monaco-switch-thumb { + position: absolute; + top: var(--vscode-strokeThickness); + left: var(--vscode-strokeThickness); + width: var(--vscode-spacing-size120); + height: var(--vscode-spacing-size120); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-button-foreground); + transition: transform 120ms ease; +} + +.monaco-switch.checked .monaco-switch-thumb { + transform: translateX(var(--vscode-spacing-size120)); +} + +/* The workbench marks high contrast with `.hc-black` / `.hc-light`; `.vscode-high-contrast` + * is the webview equivalent, and the switch is rendered in both. */ +.hc-black .monaco-switch, +.hc-light .monaco-switch, +.vscode-high-contrast .monaco-switch { + border-color: var(--vscode-contrastBorder); +} + +.monaco-reduce-motion .monaco-switch, +.monaco-reduce-motion .monaco-switch-thumb { + transition: none; +} diff --git a/src/vs/base/browser/ui/toggle/switch.ts b/src/vs/base/browser/ui/toggle/switch.ts new file mode 100644 index 00000000000000..88302c71ab739b --- /dev/null +++ b/src/vs/base/browser/ui/toggle/switch.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../dom.js'; +import { Emitter, Event } from '../../../common/event.js'; +import { Disposable } from '../../../common/lifecycle.js'; +import { HoverStyle } from '../hover/hover.js'; +import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; +import './switch.css'; + +export interface ISwitchOptions { + readonly checked?: boolean; + /** Accessible name. Also used as the hover title unless {@link title} is given. */ + readonly ariaLabel: string; + /** Hover title. Defaults to {@link ariaLabel}. */ + readonly title?: string; + readonly disabled?: boolean; +} + +/** + * A pill switch for a setting that takes effect as soon as it is flipped, e.g. enabling + * a plugin or letting a model be chosen automatically. + * + * Use this rather than a `Checkbox` when the control commits immediately; a checkbox + * reads as part of a form that is submitted later. + */ +export class Switch extends Disposable { + + private readonly _onChange = this._register(new Emitter()); + /** Fires with the new state when the user flips the switch, not when it is set in code. */ + readonly onChange: Event = this._onChange.event; + + readonly domNode: HTMLButtonElement; + + private _checked: boolean; + private _title: string; + + constructor(options: ISwitchOptions) { + super(); + this._checked = !!options.checked; + this._title = options.title ?? options.ariaLabel; + + this.domNode = dom.$('button.monaco-switch'); + this.domNode.type = 'button'; + this.domNode.setAttribute('role', 'switch'); + dom.append(this.domNode, dom.$('.monaco-switch-thumb')); + + this._register(getBaseLayerHoverDelegate().setupDelayedHover(this.domNode, () => ({ + content: this._title, + style: HoverStyle.Pointer, + }))); + + this.setAriaLabel(options.ariaLabel, options.title); + this.disabled = !!options.disabled; + this._applyState(); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + if (this.domNode.disabled) { + return; + } + this._checked = !this._checked; + this._applyState(); + this._onChange.fire(this._checked); + })); + } + + get checked(): boolean { + return this._checked; + } + + /** Sets the state without firing {@link onChange}. */ + set checked(checked: boolean) { + if (this._checked !== checked) { + this._checked = checked; + this._applyState(); + } + } + + get disabled(): boolean { + return this.domNode.disabled; + } + + set disabled(disabled: boolean) { + this.domNode.disabled = disabled; + } + + setAriaLabel(ariaLabel: string, title = ariaLabel): void { + this.domNode.setAttribute('aria-label', ariaLabel); + this._title = title; + } + + private _applyState(): void { + this.domNode.setAttribute('aria-checked', String(this._checked)); + this.domNode.classList.toggle('checked', this._checked); + } +} diff --git a/src/vs/base/test/browser/domSanitize.test.ts b/src/vs/base/test/browser/domSanitize.test.ts index 0bcf386848f1e6..eb234ecd525a62 100644 --- a/src/vs/base/test/browser/domSanitize.test.ts +++ b/src/vs/base/test/browser/domSanitize.test.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { sanitizeHtml } from '../../browser/domSanitize.js'; +import { sanitizeHtml, sanitizeSurvivingStalePolicy } from '../../browser/domSanitize.js'; import { Schemas } from '../../common/network.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js'; +/** Derived from the function under test, since dompurify may not be imported directly. */ +type SanitizeConfig = Parameters[1]; + suite('DomSanitize', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -257,4 +260,52 @@ suite('DomSanitize', () => { assert.strictEqual(result.toString(), '
allowed<forbidden>not allowed</forbidden>
'); }); }); + + suite('stale trusted types policy', () => { + + const STALE_POLICY_ERROR = `Failed to execute 'createHTML' on 'TrustedTypePolicy': The provided callback is no longer runnable.`; + + /** + * Stands in for dompurify, failing the first call the way a policy whose creating + * realm is gone fails. The sanitizer keeps one policy for the lifetime of the + * module, so a stand-in policy installed after anything has sanitized is never + * consulted; driving the call itself is what makes this independent of test order. + */ + function failingOnce(message: string) { + const configs: (SanitizeConfig | undefined)[] = []; + let failed = false; + const sanitize = (untrusted: string, config: SanitizeConfig) => { + configs.push(config); + if (!failed) { + failed = true; + throw new Error(message); + } + return `sanitized:${untrusted}`; + }; + return { sanitize, configs }; + } + + test('retries with a replacement policy when the sanitizer policy stops working', () => { + const { sanitize, configs } = failingOnce(STALE_POLICY_ERROR); + + const result = sanitizeSurvivingStalePolicy('
safe
', {}, sanitize); + + assert.deepStrictEqual( + { + result, + attempts: configs.length, + firstHadPolicy: configs[0]?.TRUSTED_TYPES_POLICY !== undefined, + retriedWithPolicy: typeof configs[1]?.TRUSTED_TYPES_POLICY?.createHTML === 'function', + }, + { result: 'sanitized:
safe
', attempts: 2, firstHadPolicy: false, retriedWithPolicy: true }, + ); + }); + + test('a failure that is not a stale policy is not retried', () => { + const { sanitize, configs } = failingOnce('Some other sanitizer failure'); + + assert.throws(() => sanitizeSurvivingStalePolicy('
safe
', {}, sanitize), /Some other sanitizer failure/); + assert.strictEqual(configs.length, 1); + }); + }); }); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index cc08b1c8f8552f..bab38bcfb7a5a9 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -8,7 +8,7 @@ import { renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { Toggle } from '../../../base/browser/ui/toggle/toggle.js'; +import { Switch } from '../../../base/browser/ui/toggle/switch.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; import { IAction, SubmenuAction, toAction } from '../../../base/common/actions.js'; @@ -49,13 +49,24 @@ export interface IActionListDelegate { */ export interface IActionListItemHover { /** - * Content to display in the hover. Can be a markdown string or an HTMLElement for full DOM control. + * Content to display in the hover. Pass a function to build the content the first + * time the panel opens, for content that is expensive to construct. */ - readonly content?: string | IMarkdownString | HTMLElement; + readonly content?: string | IMarkdownString | HTMLElement | (() => HTMLElement); /** * Optional disposable associated with the hover content (e.g. from rendered markdown). */ readonly disposable?: IDisposable; + /** + * When true, the row shows a chevron that opens the hover panel on click, and + * ArrowRight opens it from the keyboard. The panel still auto-shows on hover. + */ + readonly expandable?: boolean; + /** + * CSS class set on the hover panel while this item's hover is showing, so a + * consumer can style the panel without reaching for the content inside it. + */ + readonly panelClassName?: string; } /** @@ -136,7 +147,7 @@ export interface IActionListItem { */ readonly isSectionToggle?: boolean; /** - * Optional CSS class name to add to the row container. + * Optional CSS class names to add to the row container, separated by spaces. */ readonly className?: string; /** @@ -167,7 +178,7 @@ interface IActionMenuTemplateData { readonly submenuIndicator: HTMLElement; readonly inlineToggleContainer: HTMLElement; readonly elementDisposables: DisposableStore; - previousClassName?: string; + previousClassNames?: readonly string[]; } export const enum ActionListItemKind { @@ -222,6 +233,7 @@ class SeparatorRenderer implements IListRenderer, ISeparat } renderElement(element: IActionListItem, _index: number, templateData: ISeparatorTemplateData): void { + templateData.container.classList.toggle('has-label', !!element.label); templateData.text.textContent = element.label ?? ''; } @@ -230,6 +242,14 @@ class SeparatorRenderer implements IListRenderer, ISeparat } } +/** + * Whether the item shows a chevron that opens its panel. Items whose panel only + * auto-shows on hover get no chevron. + */ +function hasSubmenuIndicator(item: IActionListItem): boolean { + return (!!item.submenuActions?.length && !item.hover?.content) || !!item.hover?.expandable; +} + class ActionItemRenderer implements IListRenderer, IActionMenuTemplateData> { get templateId(): string { return ActionListItemKind.Action; } @@ -238,11 +258,11 @@ class ActionItemRenderer implements IListRenderer, IAction private readonly _supportsPreview: boolean, private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, private readonly _onShowSubmenu: ((item: IActionListItem) => void) | undefined, - private readonly _hasAnySubmenuActions: boolean, + private readonly _reservesSubmenuSpace: () => boolean, private readonly _groupTitleByIndex: ReadonlyMap, private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, private readonly _hideDefaultKeybindingTooltip: boolean, - private readonly _registerStandaloneToggle: (item: IActionListItem, toggle: Toggle) => IDisposable, + private readonly _registerStandaloneToggle: (item: IActionListItem, toggle: Switch) => IDisposable, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -326,14 +346,15 @@ class ActionItemRenderer implements IListRenderer, IAction // Apply optional className - clean up previous to avoid stale classes // from virtualized row reuse - if (data.previousClassName) { - data.container.classList.remove(data.previousClassName); + if (data.previousClassNames?.length) { + data.container.classList.remove(...data.previousClassNames); } - data.container.classList.toggle('action-list-custom', !!element.className); - if (element.className) { - data.container.classList.add(element.className); + const classNames = element.className?.split(/\s+/).filter(name => name.length > 0) ?? []; + data.container.classList.toggle('action-list-custom', classNames.length > 0); + if (classNames.length) { + data.container.classList.add(...classNames); } - data.previousClassName = element.className; + data.previousClassNames = classNames; data.text.textContent = stripNewlines(element.label); @@ -407,18 +428,13 @@ class ActionItemRenderer implements IListRenderer, IAction data.inlineToggleContainer.style.display = ''; data.container.classList.toggle('has-inline-toggle', !!element.inlineToggle); data.container.classList.toggle('has-standalone-toggle', !!element.standaloneToggle); - const toggle = data.elementDisposables.add(new Toggle({ - title: toggleConfig.title ?? toggleConfig.label, - isChecked: toggleConfig.checked, - actionClassName: 'action-list-inline-switch', - notFocusable: false, - inputActiveOptionBorder: undefined, - inputActiveOptionForeground: undefined, - inputActiveOptionBackground: undefined, + const toggle = data.elementDisposables.add(new Switch({ + // Callers use `title` to say why a switch is unavailable, so it names the + // control for a screen reader too rather than only appearing on hover. + ariaLabel: toggleConfig.title ?? toggleConfig.label, + checked: toggleConfig.checked, + disabled: toggleConfig.disabled, })); - if (toggleConfig.disabled) { - toggle.disable(); - } data.inlineToggleContainer.append(toggle.domNode); if (element.standaloneToggle) { data.elementDisposables.add(this._registerStandaloneToggle(element, toggle)); @@ -477,24 +493,33 @@ class ActionItemRenderer implements IListRenderer, IAction actionBar.push(toolbarActions, { icon: true, label: false }); } - // Show submenu indicator only for items with submenu actions - // but not when the item also has hover content (panel auto-shows on hover) - if (element.submenuActions?.length && !element.hover?.content) { + // Show submenu indicator for items with submenu actions, or for items that + // opt into an expandable hover panel. + if (hasSubmenuIndicator(element)) { data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = ''; + // Names what the row opens, so a screen reader can tell that there is more + // here than the row itself. Rows are recycled, so the state is always set + // rather than only added. + data.container.setAttribute('aria-haspopup', element.hover?.expandable ? 'dialog' : 'menu'); + data.container.setAttribute('aria-expanded', 'false'); data.elementDisposables.add(dom.addDisposableListener(data.submenuIndicator, dom.EventType.CLICK, (e) => { e.stopPropagation(); this._onShowSubmenu?.(element); })); - } else if (this._hasAnySubmenuActions) { + } else if (this._reservesSubmenuSpace()) { // Reserve space for alignment when other items have submenus data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = 'hidden'; + data.container.removeAttribute('aria-haspopup'); + data.container.removeAttribute('aria-expanded'); } else { data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = 'none'; + data.container.removeAttribute('aria-haspopup'); + data.container.removeAttribute('aria-expanded'); } } @@ -615,10 +640,13 @@ export interface IActionListOptions { readonly initialFocusItemId?: string; /** - * When false, non-submenu items do not reserve space for the submenu chevron. - * Defaults to true for alignment consistency. + * Controls the gutter kept for the submenu chevron on items that have none. + * - `true` (default): kept while some item shows a chevron. + * - `'always'`: kept regardless, for lists whose items gain and lose their chevron + * while the popup stays open, where a collapsing gutter would shift every row. + * - `false`: never kept. */ - readonly reserveSubmenuSpace?: boolean; + readonly reserveSubmenuSpace?: boolean | 'always'; /** * When true, items without an explicit `tooltip` or `hover` do not get a @@ -702,6 +730,9 @@ export class ActionListWidget extends Disposable { private _submenuShowTimeout: ReturnType | undefined; private _currentSubmenuWidget: ActionListWidget | undefined; private _currentSubmenuElement: IActionListItem | undefined; + private _submenuPanelClassName: string | undefined; + /** The row currently reporting `aria-expanded`, reset when its panel closes. */ + private _expandedTrigger: HTMLElement | undefined; private readonly _collapsedSections = new Set(); private _filterText = ''; @@ -714,7 +745,7 @@ export class ActionListWidget extends Disposable { private _headerContainer: HTMLElement | undefined; private readonly _filterCts = this._register(new MutableDisposable()); private readonly _groupTitleByIndex = new Map(); - private readonly _standaloneToggles = new Map, Toggle>(); + private readonly _standaloneToggles = new Map, Switch>(); private _visibleMenuItems: readonly IActionListItem[]; private readonly _onDidRequestLayout = this._register(new Emitter()); @@ -762,6 +793,18 @@ export class ActionListWidget extends Disposable { this._submenuContainer.tabIndex = -1; this.domNode.append(this._submenuContainer); + // A panel showing only hover content has no inner list to own the keyboard, so + // the way back to the row it belongs to lives here. A panel that does have a + // submenu list stops these keys before they reach this handler. + this._register(dom.addDisposableListener(this._submenuContainer, 'keydown', (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'Escape') { + return; + } + dom.EventHelper.stop(e, true); + this._hideSubmenu(); + this._list.domFocus(); + })); + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseenter', () => { this._cancelSubmenuHide(); })); @@ -792,11 +835,15 @@ export class ActionListWidget extends Disposable { }; - const reserveSubmenuSpace = this._options?.reserveSubmenuSpace ?? true; - const hasAnySubmenuActions = reserveSubmenuSpace && items.some(item => !!item.submenuActions?.length && !item.hover?.content); + // Read on every render: whether any item opens a panel can change when the items + // are rebuilt in place, and a stale answer shifts every row by a chevron's width. + const reservesSubmenuSpace = () => { + const reserve = this._options?.reserveSubmenuSpace ?? true; + return reserve === 'always' || (reserve && this._allMenuItems.some(hasSubmenuIndicator)); + }; this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(this._supportsPreview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._groupTitleByIndex, this._options?.linkHandler, this._options?.hideDefaultKeybindingTooltip ?? false, (item, toggle) => { + new ActionItemRenderer(this._supportsPreview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), reservesSubmenuSpace, this._groupTitleByIndex, this._options?.linkHandler, this._options?.hideDefaultKeybindingTooltip ?? false, (item, toggle) => { this._standaloneToggles.set(item, toggle); return toDisposable(() => { if (this._standaloneToggles.get(item) === toggle) { @@ -825,8 +872,7 @@ export class ActionListWidget extends Disposable { } if (element.hover?.content && !element.ariaDescription && !element.description) { const hoverContent = element.hover.content; - const hoverText = typeof hoverContent === 'string' ? hoverContent : isMarkdownString(hoverContent) ? hoverContent.value : dom.isHTMLElement(hoverContent) ? hoverContent.textContent ?? undefined : undefined; - if (hoverText && (!element.detail || stripNewlines(element.detail) !== stripNewlines(hoverText))) { + const hoverText = typeof hoverContent === 'string' ? hoverContent : isMarkdownString(hoverContent) ? hoverContent.value : dom.isHTMLElement(hoverContent) ? hoverContent.textContent ?? undefined : undefined; if (hoverText && (!element.detail || stripNewlines(element.detail) !== stripNewlines(hoverText))) { label = label + ', ' + stripNewlines(hoverText); } } @@ -1011,12 +1057,16 @@ export class ActionListWidget extends Disposable { const focused = this._list.getFocus(); if (focused.length > 0) { const element = this._list.element(focused[0]); - if (element?.submenuActions?.length) { + if (element?.submenuActions?.length || element?.hover?.expandable) { dom.EventHelper.stop(e, true); const rowElement = this._getRowElement(focused[0]); if (rowElement) { this._showSubmenuForElement(element, rowElement); - this._currentSubmenuWidget?.focus(); + if (this._currentSubmenuWidget) { + this._currentSubmenuWidget.focus(); + } else { + this._submenuContainer.focus(); + } } } } @@ -1206,6 +1256,9 @@ export class ActionListWidget extends Disposable { // Capture whether the filter input currently has focus before splice // which may cause DOM changes that shift focus. const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); + // Focus is only ours to restore if the list had it. Something outside the list, + // like a footer control, can rebuild the items while keeping focus itself. + const listHasFocus = dom.isAncestorOfActiveElement(this._list.getHTMLElement()); this._visibleMenuItems = visible; this._list.splice(0, this._list.length, visible); @@ -1232,9 +1285,12 @@ export class ActionListWidget extends Disposable { if ((el.item as { id?: string })?.id === focusedItemId) { this._list.setFocus([i]); this._list.reveal(i); - // Move DOM focus back to the list: the splice above destroyed - // the previously focused row, leaving DOM focus on the body. - this._list.domFocus(); + // Move DOM focus back to the list when the list had it: the splice + // above destroyed the previously focused row, leaving DOM focus on + // the body. + if (listHasFocus) { + this._list.domFocus(); + } break; } } @@ -1296,16 +1352,21 @@ export class ActionListWidget extends Disposable { /** * Replaces the items in the list in place, preserving the current filter, - * without closing or repositioning the widget. When {@link focusItemId} is - * provided, that item ({@link IActionListItem.item}'s `id`) is focused; - * otherwise the previously focused item is preserved (matched by id). + * without closing the widget. When {@link focusItemId} is provided, that item + * ({@link IActionListItem.item}'s `id`) is focused; otherwise the previously + * focused item is preserved (matched by id). The widget only re-measures when + * the number of visible rows changed. */ updateItems(items: readonly IActionListItem[], focusItemId?: string): void { this._allMenuItems = [...items]; - // Don't fire a layout request: the item set keeps the same shape, so the - // widget size is unchanged and repositioning could mis-anchor if the - // anchor element was re-rendered by the action that triggered this update. + const previousVisibleCount = this._visibleMenuItems.length; + // Re-layout only when the number of rows changed. Holding the widget still + // otherwise keeps it from re-anchoring against a trigger that the same action + // just re-rendered. this._applyFilter(false, false); + if (this._visibleMenuItems.length !== previousVisibleCount) { + this._onDidRequestLayout.fire(); + } if (focusItemId !== undefined) { this.focusItemById(focusItemId); } @@ -1467,9 +1528,11 @@ export class ActionListWidget extends Disposable { this._list.layout(height, width); this.domNode.style.height = `${height}px`; - // Place filter container on the preferred side. - if (this._filterContainer && this._filterContainer.parentElement) { - this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); + // Keep the filter above the list. Skipped when the caller mounted the filter + // somewhere else entirely (e.g. inside a tab bar), where it has no list to sit above. + const listParent = this.domNode.parentElement; + if (listParent && this._filterContainer?.parentElement === listParent) { + listParent.insertBefore(this._filterContainer, this.domNode); } } @@ -1655,7 +1718,7 @@ export class ActionListWidget extends Disposable { if (element.standaloneToggle) { this._list.setSelection([]); const toggle = this._standaloneToggles.get(element); - if (toggle?.enabled) { + if (toggle && !toggle.disabled) { toggle.checked = !toggle.checked; element.standaloneToggle.onChange(toggle.checked); } @@ -1797,9 +1860,20 @@ export class ActionListWidget extends Disposable { this._currentSubmenuElement = element; this._clearSubmenuContainer(); + // Marks the row the panel belongs to as open, so a screen reader can tell what + // ArrowRight opened. Reset in `_clearSubmenuContainer`. + anchor.setAttribute('aria-expanded', 'true'); + this._expandedTrigger = anchor; + + // Set after clearing, which is what removes the previous item's class. + this._submenuPanelClassName = element.hover?.panelClassName; + if (this._submenuPanelClassName) { + this._submenuContainer.classList.add(this._submenuPanelClassName); + } + // When the item has hover content, render it as a header let hoverHeader: HTMLElement | undefined; - const hoverContent = element.hover?.content; + const hoverContent = typeof element.hover?.content === 'function' ? element.hover.content() : element.hover?.content; if (hoverContent) { if (dom.isHTMLElement(hoverContent)) { hoverHeader = hoverContent; @@ -1840,7 +1914,16 @@ export class ActionListWidget extends Disposable { // Show container before creating widget so List can measure during construction this._submenuContainer.style.display = ''; this._submenuContainer.style.position = 'absolute'; - this._submenuContainer.removeAttribute('role'); + // An expandable hover panel is a named region the user travels into, so it says + // what it is. A panel carrying a submenu list leaves the semantics to that list. + if (element.hover?.expandable) { + this._submenuContainer.setAttribute('role', 'dialog'); + if (element.label) { + this._submenuContainer.setAttribute('aria-label', element.label); + } + } else { + this._submenuContainer.removeAttribute('role'); + } const anchorRect = anchor.getBoundingClientRect(); const parentRect = this.domNode.getBoundingClientRect(); @@ -2018,6 +2101,18 @@ export class ActionListWidget extends Disposable { if (this._submenuContainer.contains(dom.getActiveElement())) { this._list.domFocus(); } + if (this._submenuPanelClassName) { + this._submenuContainer.classList.remove(this._submenuPanelClassName); + this._submenuPanelClassName = undefined; + } + this._submenuContainer.removeAttribute('role'); + this._submenuContainer.removeAttribute('aria-label'); + // The row that opened the panel is no longer expanded. Skipped when the row has + // since been recycled onto an item with no panel, which drops the attribute. + if (this._expandedTrigger?.hasAttribute('aria-expanded')) { + this._expandedTrigger.setAttribute('aria-expanded', 'false'); + } + this._expandedTrigger = undefined; dom.clearNode(this._submenuContainer); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index eed7de9a046366..d692df6098b18a 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -371,58 +371,6 @@ color: var(--vscode-foreground); } -/* Inline pill switch (restyles the base Toggle as an iOS-style switch) */ -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { - position: relative; - flex-shrink: 0; - float: none; - margin: 0; - padding: 0; - width: 26px; - height: 16px; - border-radius: var(--vscode-cornerRadius-circle); - border: var(--vscode-strokeThickness) solid var(--vscode-checkbox-border); - background-color: var(--vscode-checkbox-background); - overflow: visible; - transition: background-color 0.1s ease, border-color 0.1s ease; -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch:hover { - background-color: var(--vscode-checkbox-background); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch::before { - content: ""; - position: absolute; - top: 1px; - left: 1px; - width: 12px; - height: 12px; - border-radius: 50%; - background-color: var(--vscode-checkbox-foreground); - transition: left 0.1s ease, background-color 0.1s ease; -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked { - background-color: var(--vscode-button-background); - border-color: var(--vscode-button-background); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked:hover { - background-color: var(--vscode-button-hoverBackground, var(--vscode-button-background)); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked::before { - left: 11px; - background-color: var(--vscode-button-foreground); -} - -.hc-black .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch, -.hc-light .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { - border-color: var(--vscode-contrastBorder); -} - - /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { /* Override the row gap so group-title and toolbar sit flush */ diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css index 2c0141dd6394c6..0e9454a42f8e14 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css @@ -26,3 +26,37 @@ .action-widget .tabbed-action-list-tabbar .monaco-custom-radio > .monaco-button:not(.active) { color: var(--vscode-descriptionForeground); } + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; + margin: 0; + border: none; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action.align-end { + margin-left: auto; +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action:hover, +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action.checked { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.action-widget .tabbed-action-list-footer, +.action-widget .tabbed-action-list-empty { + flex: 0 0 auto; +} diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index c118da4c0deaf2..735bce4d37466f 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -8,7 +8,7 @@ import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWi import { Radio } from '../../../base/browser/ui/radio/radio.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -42,6 +42,22 @@ export interface ITabDescriptor { readonly icon?: ThemeIcon; } +/** + * An icon button rendered at the trailing edge of the tab bar. Unlike a tab, + * running it does not change the active tab. + */ +export interface ITabBarAction { + /** Stable identifier, used as the button's `data-id` for tests. */ + readonly id: string; + readonly icon: ThemeIcon; + readonly tooltip: string; + /** When true, the button is pushed to the far end of the tab bar. */ + readonly alignEnd?: boolean; + /** When true, the button renders in its pressed state. */ + readonly checked?: boolean; + run(): void; +} + /** * Options for {@link TabbedActionListWidget.show}. The widget renders a * tab bar above an `ActionList` inside a single popup. Consumers describe @@ -67,6 +83,31 @@ export interface ITabbedActionListShowOptions { readonly width?: number; /** Optional class name to add to the tab bar element (in addition to `.tabbed-action-list-tabbar`). Must be a single class. */ readonly tabBarClassName?: string; + /** + * Computes the class names on the popup's `.action-widget` element. Re-evaluated + * whenever the popup re-renders or its list is refreshed, so state that changes + * while the popup stays open is not replayed stale on a tab switch. + */ + readonly widgetClassNames?: (activeTab: string) => readonly string[]; + /** Optional icon buttons rendered after the tabs. */ + readonly tabBarActions?: readonly ITabBarAction[]; + /** + * When tabs show their label beside their icon. `active` labels only the active tab, + * and a tab with no icon always shows its label. Defaults to `always`. + */ + readonly tabLabels?: 'always' | 'active' | 'never'; + /** + * When true, the list's filter row is rendered inside the tab bar in place of the + * tabs, rather than as its own row below them. + */ + readonly filterInTabBar?: boolean; + /** Renders content pinned below the list, e.g. a persistent option row. */ + renderFooter?(container: HTMLElement, activeTab: string): IDisposable; + /** + * Renders the body when the active tab has no items, e.g. a sign-in prompt. + * When it returns `undefined` the empty list is shown instead. + */ + renderEmpty?(container: HTMLElement, activeTab: string): IDisposable | undefined; } /** @@ -82,6 +123,7 @@ export class TabbedActionListWidget extends Disposable { private readonly _activePopup = this._register(new MutableDisposable()); private _swappingTab = false; + private _refreshActiveList: (() => void) | undefined; get isVisible(): boolean { return !!this._activePopup.value; @@ -130,19 +172,38 @@ export class TabbedActionListWidget extends Disposable { const renderDisposables = new DisposableStore(); const widget = dom.append(container, dom.$('.action-widget')); + let widgetClassNames: readonly string[] = []; + const applyWidgetClassNames = () => { + const next = options.widgetClassNames?.(activeTab) ?? []; + const removed = widgetClassNames.filter(name => !next.includes(name)); + const added = next.filter(name => !widgetClassNames.includes(name)); + if (removed.length) { + widget.classList.remove(...removed); + } + if (added.length) { + widget.classList.add(...added); + } + widgetClassNames = next; + }; + applyWidgetClassNames(); + + // Invisible layers that swallow the mouse events which follow the one that + // opened the popup. Without them a trigger that opens on mouse down is + // dismissed by its own mouse up. + const block = dom.append(container, dom.$('.context-view-block')); + renderDisposables.add(dom.addDisposableGenericMouseDownListener(block, e => e.stopPropagation())); + const pointerBlock = dom.append(container, dom.$('.context-view-pointerBlock')); + renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.POINTER_MOVE, () => pointerBlock.remove())); + renderDisposables.add(dom.addDisposableGenericMouseDownListener(pointerBlock, () => pointerBlock.remove())); const tabBar = dom.append(widget, dom.$('.tabbed-action-list-tabbar')); if (options.tabBarClassName) { tabBar.classList.add(options.tabBarClassName); } - const radio = renderDisposables.add(new Radio({ - items: options.tabs.map(tab => { - const label = tab.label ?? tab.id; - const text = tab.icon ? `$(${tab.icon.id}) ${label}` : label; - return { text, tooltip: tab.tooltip ?? label, isActive: tab.id === activeTab }; - }), - })); - tabBar.appendChild(radio.domNode); + // A consumer showing a filter hides the strip and takes its place, so the + // trailing actions never move. + const tabStrip = dom.append(tabBar, dom.$('.tabbed-action-list-tabstrip')); + const filterSlot = dom.append(tabBar, dom.$('.tabbed-action-list-filter-slot')); const activateTab = (next: string) => { if (next === activeTab) { @@ -153,6 +214,17 @@ export class TabbedActionListWidget extends Disposable { this.show({ ...options, initialTab: next }); }; + const radio = renderDisposables.add(new Radio({ + items: options.tabs.map(tab => { + const label = tab.label ?? tab.id; + const iconPrefix = tab.icon ? `$(${tab.icon.id})` : ''; + const labelMode = options.tabLabels ?? 'always'; + const showsLabel = !iconPrefix || labelMode === 'always' || (labelMode === 'active' && tab.id === activeTab); + const text = showsLabel ? (iconPrefix ? `${iconPrefix} ${label}` : label) : iconPrefix; + return { text, tooltip: tab.tooltip ?? label, ariaLabel: label, isActive: tab.id === activeTab }; + }), + })); + tabStrip.appendChild(radio.domNode); renderDisposables.add(radio.onDidSelect(index => { const next = options.tabs[index]; if (next) { @@ -160,7 +232,28 @@ export class TabbedActionListWidget extends Disposable { } })); + for (const tabAction of options.tabBarActions ?? []) { + const container = tabAction.alignEnd ? tabBar : tabStrip; + const button = dom.append(container, dom.$('button.tabbed-action-list-tabbar-action')); + button.classList.toggle('align-end', !!tabAction.alignEnd); + button.classList.toggle('checked', !!tabAction.checked); + button.dataset.id = tabAction.id; + button.title = tabAction.tooltip; + button.ariaLabel = tabAction.tooltip; + // Only an action with a real on/off state announces as a toggle. A + // momentary one would otherwise read as a toggle that is switched off. + if (tabAction.checked !== undefined) { + button.setAttribute('aria-pressed', String(tabAction.checked)); + } + dom.append(button, dom.$(`span${ThemeIcon.asCSSSelector(tabAction.icon)}`)); + renderDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + tabAction.run(); + })); + } + const { items, listOptions } = options.createActionList(activeTab); + const emptyBody = items.length === 0 ? this._renderEmptyBody(widget, options, activeTab, renderDisposables) : undefined; const list = renderDisposables.add(this._instantiationService.createInstance( ActionList, options.user, @@ -172,39 +265,76 @@ export class TabbedActionListWidget extends Disposable { options.anchor, )); listRef = list; + // Rebuilding has to ask the consumer again, since what the popup shows can + // depend on state that changed while it stayed open. + this._refreshActiveList = () => { + applyWidgetClassNames(); + list.updateItems(options.createActionList(activeTab).items); + }; + renderDisposables.add(toDisposable(() => { + this._refreshActiveList = undefined; + })); - if (list.filterContainer) { - widget.appendChild(list.filterContainer); + if (!emptyBody) { + if (list.headerContainer) { + widget.appendChild(list.headerContainer); + } + if (list.filterContainer) { + // The filter takes the tabs' place inside the bar, so the trailing + // actions stay put and no extra row appears. + (options.filterInTabBar ? filterSlot : widget).appendChild(list.filterContainer); + } + widget.appendChild(list.domNode); + if (list.footerContainer) { + widget.appendChild(list.footerContainer); + } + } + + if (options.renderFooter) { + const footer = dom.append(widget, dom.$('.tabbed-action-list-footer')); + renderDisposables.add(options.renderFooter(footer, activeTab)); } - widget.appendChild(list.domNode); const width = list.layout(0); widget.style.width = `${options.width ?? width}px`; - list.focus(); + if (emptyBody) { + // The list is not in the DOM at all, so focusing it would drop focus + // out of the popup. The active tab is the nearest thing to act on, and + // it leads to the empty body's own action. + radio.focusActiveItem(); + } else { + list.focus(); + } // Keyboard nav. Bound to the popup widget so we don't // observe unrelated document-wide keypresses. renderDisposables.add(dom.addStandardDisposableListener(widget, 'keydown', e => { const target = e.target as HTMLElement | null; const onTabBar = !!target?.closest('.tabbed-action-list-tabbar'); + const onFooter = !!target?.closest('.tabbed-action-list-footer'); const onEditable = !!target?.closest('input, textarea, [contenteditable="true"]'); + // The empty body and the hover panel carry controls of their own, e.g. a + // sign-in button or the detail card's pin. Keys pressed there belong to + // those controls rather than to the list sitting behind them. + const onOwnControls = !!target?.closest('.tabbed-action-list-empty, .action-list-submenu-panel'); + const listNavigation = !onTabBar && !onFooter && !onOwnControls; if (e.keyCode === KeyCode.Escape) { dom.EventHelper.stop(e, true); hide(); return; } - if (e.keyCode === KeyCode.Enter && !onTabBar) { + if (e.keyCode === KeyCode.Enter && listNavigation) { dom.EventHelper.stop(e, true); list.acceptSelected(); return; } - if (e.keyCode === KeyCode.UpArrow && !onTabBar) { + if (e.keyCode === KeyCode.UpArrow && listNavigation) { dom.EventHelper.stop(e, true); list.focusPrevious(); return; } - if (e.keyCode === KeyCode.DownArrow && !onTabBar) { + if (e.keyCode === KeyCode.DownArrow && listNavigation) { dom.EventHelper.stop(e, true); list.focusNext(); return; @@ -212,7 +342,7 @@ export class TabbedActionListWidget extends Disposable { if (e.keyCode !== KeyCode.LeftArrow && e.keyCode !== KeyCode.RightArrow) { return; } - if (onEditable && !onTabBar) { + if (onFooter || onOwnControls || (onEditable && !onTabBar)) { return; } const currentIndex = options.tabs.findIndex(t => t.id === activeTab); @@ -272,6 +402,30 @@ export class TabbedActionListWidget extends Disposable { this._activePopup.value = undefined; } + /** + * Rebuilds the active tab's items and the popup's class names in place, keeping its + * position and whatever currently has focus. Use when an action inside the popup + * changes what it shows but should not dismiss it. + */ + refreshActiveList(): void { + this._refreshActiveList?.(); + } + + /** Renders the caller's empty body, or nothing when it declines to handle the empty tab. */ + private _renderEmptyBody(widget: HTMLElement, options: ITabbedActionListShowOptions, activeTab: string, disposables: DisposableStore): HTMLElement | undefined { + if (!options.renderEmpty) { + return undefined; + } + const body = dom.append(widget, dom.$('.tabbed-action-list-empty')); + const rendered = options.renderEmpty(body, activeTab); + if (!rendered) { + body.remove(); + return undefined; + } + disposables.add(rendered); + return body; + } + override dispose(): void { this._activePopup.value = undefined; super.dispose(); diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index afb61a7f195ece..83e8dc61fb5996 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -187,7 +187,7 @@ suite('ActionListWidget', () => { standaloneClass: row?.classList.contains('has-standalone-toggle'), label: row?.querySelector('.title')?.textContent, toggleLabelCount: row?.querySelectorAll('.action-list-item-inline-toggle-label').length, - switchChecked: row?.querySelector('.action-list-inline-switch')?.classList.contains('checked'), + switchChecked: row?.querySelector('.monaco-switch')?.classList.contains('checked'), title: row?.title, }, { checked: true, @@ -216,17 +216,17 @@ suite('ActionListWidget', () => { widget.focus(); widget.acceptSelected(); - const toggle = widget.domNode.querySelector('.action-list-inline-switch'); + const toggle = widget.domNode.querySelector('.monaco-switch'); assert.deepStrictEqual({ changeCount, checked: toggle?.classList.contains('checked'), - disabled: toggle?.getAttribute('aria-disabled'), + disabled: (toggle as HTMLButtonElement | null)?.disabled, title: toggle?.getAttribute('aria-label'), }, { changeCount: 0, checked: true, - disabled: 'true', + disabled: true, title: 'Managed by your organization', }); }); @@ -557,6 +557,97 @@ suite('ActionListWidget', () => { ); }); + test('an expandable row names the panel it opens, and stops when it closes', () => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('auto'), hover: { content: 'panel', expandable: true } }, action('plain')], + listOptions: { reserveSubmenuSpace: 'always' }, + }); + const rows = () => Array.from(widget.domNode.querySelectorAll('.monaco-list-row.action')); + const state = () => rows().map(row => ({ + haspopup: row.getAttribute('aria-haspopup'), + expanded: row.getAttribute('aria-expanded'), + })); + + const initial = state(); + // The chevron is what opens the panel; ArrowRight does the same from the keyboard. + rows()[0].querySelector('.action-list-submenu-indicator.has-submenu')?.click(); + const opened = state(); + const panel = widget.domNode.querySelector('.action-list-submenu-panel'); + const panelRole = panel?.getAttribute('role'); + const panelLabel = panel?.getAttribute('aria-label'); + // Escape inside the panel is the way back to the row. + panel?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + const closed = state(); + + assert.deepStrictEqual( + { initial, opened, closed, panelRole, panelLabel }, + { + // The plain row opens nothing, so it says nothing. + initial: [{ haspopup: 'dialog', expanded: 'false' }, { haspopup: null, expanded: null }], + opened: [{ haspopup: 'dialog', expanded: 'true' }, { haspopup: null, expanded: null }], + closed: [{ haspopup: 'dialog', expanded: 'false' }, { haspopup: null, expanded: null }], + panelRole: 'dialog', + panelLabel: 'auto', + }, + ); + }); + + test('the submenu gutter follows the items the list currently holds', () => { + const expandable = (id: string): IActionListItem => ({ ...action(id), hover: { content: 'panel', expandable: true } }); + const gutters = (widget: ActionListWidget) => + Array.from(widget.domNode.querySelectorAll('.monaco-list-row .action-list-submenu-indicator')) + .map(el => el.style.display === 'none' ? 'none' : (el.style.visibility || 'shown')); + + const widget = createActionListWidget(disposables, { items: [expandable('one'), action('two')] }); + const always = createActionListWidget(disposables, { + items: [expandable('one'), action('two')], + listOptions: { reserveSubmenuSpace: 'always' }, + }); + + const before = { byDefault: gutters(widget), always: gutters(always) }; + // The chevrons go away, so by default the gutter goes with them. + widget.updateItems([action('one'), action('two')]); + always.updateItems([action('one'), action('two')]); + + assert.deepStrictEqual( + { before, afterLosingChevrons: { byDefault: gutters(widget), always: gutters(always) } }, + { + before: { byDefault: ['shown', 'hidden'], always: ['shown', 'hidden'] }, + afterLosingChevrons: { byDefault: ['none', 'none'], always: ['hidden', 'hidden'] }, + }, + ); + }); + + test('rebuilding the items in place re-measures only when the row count changed', () => { + const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); + const layouts: string[] = []; + disposables.add(widget.onDidRequestLayout(() => { layouts.push(getVisibleRowText(widget).join(',')); })); + + widget.updateItems([action('one'), action('two-renamed')]); + const afterSameCount = layouts.length; + widget.updateItems([action('one'), action('two-renamed'), action('three')]); + + assert.deepStrictEqual( + { afterSameCount, afterGrowing: layouts, rows: getVisibleRowText(widget) }, + { afterSameCount: 0, afterGrowing: ['one,two-renamed,three'], rows: ['one', 'two-renamed', 'three'] }, + ); + }); + + test('rebuilding the items in place leaves focus alone when the list does not have it', () => { + const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); + const outside = document.createElement('button'); + document.body.appendChild(outside); + disposables.add({ dispose: () => outside.remove() }); + outside.focus(); + + widget.updateItems([action('one'), action('two'), action('three')]); + + assert.deepStrictEqual( + { focusStayedOutside: document.activeElement === outside, rows: getVisibleRowText(widget) }, + { focusStayedOutside: true, rows: ['one', 'two', 'three'] }, + ); + }); + test('shows a row hover panel once the hover delay elapses', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const widget = createActionListWidget(disposables, { items: [{ ...action('auto'), hover: { content: 'Auto routes based on your task' } }, action('other')], diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index 59c616cc0916d6..59dba6c39fefee 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -142,6 +142,47 @@ suite('TabbedActionListWidget', () => { assert.deepStrictEqual(calls, ['Remote']); }); + test('popup class names are re-read on tab switch, not replayed from show()', () => { + const { widget } = createWidget(disposables); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + let dimmed = false; + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Local' }, { id: 'Remote' }], + initialTab: 'Local', + widgetClassNames: tab => ['picker', `tab-${tab}`, ...(dimmed ? ['dimmed'] : [])], + createActionList: () => ({ items: [action('a')] }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const classes = () => { + const popup = document.querySelector('.action-widget:not(.action-list-submenu-panel)'); + return [...(popup?.classList ?? [])].filter(name => name !== 'action-widget').sort(); + }; + + const onShow = classes(); + // State the popup reports changes while it stays open. + dimmed = true; + widget.refreshActiveList(); + const afterRefresh = classes(); + // Switching tabs re-renders the popup, which must not bring back the old state. + document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[1].click(); + const afterTabSwitch = classes(); + + assert.deepStrictEqual( + { onShow, afterRefresh, afterTabSwitch }, + { + onShow: ['picker', 'tab-Local'], + afterRefresh: ['dimmed', 'picker', 'tab-Local'], + afterTabSwitch: ['dimmed', 'picker', 'tab-Remote'], + }, + ); + }); + test('hide() then show() resets visibility cleanly', () => { const { widget } = createWidget(disposables); const anchor = document.createElement('div'); diff --git a/src/vs/sessions/test/browser/permissionPickerList.fixture.ts b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts index f5759e41ffe7c1..3a99d1cb3ec4ab 100644 --- a/src/vs/sessions/test/browser/permissionPickerList.fixture.ts +++ b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts @@ -12,7 +12,7 @@ function render(context: ComponentFixtureContext, checked: boolean, width = 320) renderPermissionPickerList(context, { showStandaloneSandboxToggle: true, sandboxingEnabled: checked, width }); const row = context.container.querySelector('.has-standalone-toggle'); - const toggle = row?.querySelector('.action-list-inline-switch'); + const toggle = row?.querySelector('.monaco-switch'); if (!row || !toggle) { throw new Error('Expected a standalone sandbox toggle row'); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 28aec0ee2b9923..f5ca75547dc6e2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -13,6 +13,7 @@ import { IListRenderer } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Switch } from '../../../../../base/browser/ui/toggle/switch.js'; import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -1694,10 +1695,9 @@ export class McpListWidget extends Disposable { private appendInstalledServerToggle(parent: HTMLElement, getEntry: () => IMcpInstalledEntry): { readonly element: HTMLButtonElement; update(): void } { const label = getMcpEntryLabel(getEntry()); let enabled = this.isInstalledEntryEnabled(getEntry()); - const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; - switchElement.type = 'button'; - switchElement.setAttribute('role', 'switch'); - switchElement.setAttribute('aria-checked', String(enabled)); + const toggle = this.cardDisposables.add(new Switch({ ariaLabel: label, checked: enabled })); + const switchElement = toggle.domNode; + DOM.append(parent, switchElement); const updateLabel = () => { const blockedByPlugin = getMcpDisabledReason(getEntry())?.source === 'plugin'; const toggleLabel = enabled @@ -1706,16 +1706,11 @@ export class McpListWidget extends Disposable { const accessibleLabel = blockedByPlugin ? localize('mcpServerManagedByPluginAria', "{0} is disabled by its plugin", label) : toggleLabel; - switchElement.setAttribute('aria-label', accessibleLabel); - switchElement.title = accessibleLabel; + toggle.setAriaLabel(accessibleLabel); }; - switchElement.classList.toggle('checked', enabled); updateLabel(); - DOM.append(switchElement, $('.plugin-enable-switch-thumb')); - this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { - enabled = !enabled; - switchElement.classList.toggle('checked', enabled); - switchElement.setAttribute('aria-checked', String(enabled)); + this.cardDisposables.add(toggle.onChange(checked => { + enabled = checked; updateLabel(); this.setInstalledEntryEnabled(getEntry(), enabled); status(enabled @@ -1724,9 +1719,8 @@ export class McpListWidget extends Disposable { })); const update = () => { enabled = this.isInstalledEntryEnabled(getEntry()); - switchElement.disabled = getMcpDisabledReason(getEntry())?.source === 'plugin'; - switchElement.classList.toggle('checked', enabled); - switchElement.setAttribute('aria-checked', String(enabled)); + toggle.disabled = getMcpDisabledReason(getEntry())?.source === 'plugin'; + toggle.checked = enabled; updateLabel(); }; update(); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index e83efb3cc2ba37..bfb2ea45c93177 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -2708,57 +2708,7 @@ per-word capitalization does not survive translation. */ background: color-mix(in srgb, var(--vscode-list-hoverBackground) 65%, transparent); } -.plugin-list-widget .plugin-enable-switch { - position: relative; - flex: 0 0 auto; - width: var(--vscode-spacing-size280); - height: var(--vscode-spacing-size160); - padding: 0; - border: var(--vscode-strokeThickness) solid transparent; - border-radius: var(--vscode-cornerRadius-circle); - background: color-mix(in srgb, var(--vscode-descriptionForeground) 36%, transparent); - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.plugin-list-widget .plugin-enable-switch.checked { - background: var(--vscode-button-background); - border-color: var(--vscode-button-background); -} - -.plugin-list-widget .plugin-enable-switch:disabled { - cursor: default; - opacity: 0.5; -} - -.plugin-list-widget .plugin-enable-switch:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: var(--vscode-spacing-size20); -} - -.plugin-list-widget .plugin-enable-switch-thumb { - position: absolute; - top: var(--vscode-strokeThickness); - left: var(--vscode-strokeThickness); - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - border-radius: var(--vscode-cornerRadius-circle); - background: var(--vscode-button-foreground); - transition: transform 120ms ease; -} - -.plugin-list-widget .plugin-enable-switch.checked .plugin-enable-switch-thumb { - transform: translateX(var(--vscode-spacing-size120)); -} - -.vscode-high-contrast .plugin-list-widget .plugin-enable-switch { - border-color: var(--vscode-contrastBorder); -} - -.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch, -.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch-thumb { - transition: none; -} +/* The enablement switch is the shared `Switch` widget; its styling lives in base. */ .plugin-list-widget .plugin-list-item-install-button { font-size: var(--vscode-agents-fontSize-body2); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index a6d1729aa6b96c..a4f1b956ff429c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -41,6 +41,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ChatConfiguration } from '../../common/constants.js'; import { IAICustomizationItemsModel } from './aiCustomizationItemsModel.js'; import { UpdateAgentPluginsCommandId } from '../chat.js'; +import { Switch } from '../../../../../base/browser/ui/toggle/switch.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { getErrorMessage } from '../../../../../base/common/errors.js'; @@ -1424,10 +1425,9 @@ export class PluginListWidget extends Disposable { private appendInstalledPluginToggle(parent: HTMLElement, row: HTMLElement, primaryAction: HTMLElement, item: IInstalledPluginItem): HTMLButtonElement { let renderedState = item.plugin.enablement.get(); - const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; - switchElement.type = 'button'; - switchElement.setAttribute('role', 'switch'); - DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + const toggle = this.cardDisposables.add(new Switch({ ariaLabel: item.name })); + const switchElement = toggle.domNode; + DOM.append(parent, switchElement); const update = (state: ContributionEnablementState, blocked: boolean) => { renderedState = state; const checked = isContributionEnabled(state); @@ -1436,11 +1436,9 @@ export class PluginListWidget extends Disposable { ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); const accessibleLabel = blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel; - switchElement.disabled = blocked; - switchElement.setAttribute('aria-checked', String(checked)); - switchElement.setAttribute('aria-label', accessibleLabel); - switchElement.classList.toggle('checked', checked); - switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + toggle.disabled = blocked; + toggle.checked = checked; + toggle.setAriaLabel(accessibleLabel, blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel); row.classList.toggle('disabled', !checked || blocked); primaryAction.setAttribute('aria-label', localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin))); }; @@ -1449,7 +1447,7 @@ export class PluginListWidget extends Disposable { const blocked = item.plugin.policyBlocked?.read(reader) === true; update(state, blocked); })); - this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { + this.cardDisposables.add(toggle.onChange(() => { const nextState = getToggledPluginEnablementState(renderedState); update(nextState, isPluginPolicyBlocked(item.plugin)); this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 4e390359e09dfa..2afcd194db8b78 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -274,6 +274,12 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentsWindow: { default: true }, }, + 'chat.experimentalModelPicker': { + type: 'boolean', + description: nls.localize('chat.experimentalModelPicker', "When enabled, the model picker uses a tab per model provider and configures thinking effort and context from a detail card next to each model, instead of a separate configuration button."), + default: false, + tags: ['experimental'], + }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts index a3415200fb1f6a..0e212fa7d26fb6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts @@ -11,7 +11,7 @@ import { equals } from '../../../../../../base/common/objects.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { createModelConfigurationActions, ILanguageModelsService } from '../../../common/languageModels.js'; import { computeStoredConfiguration, extractSchemaDefaults, filterConfigurationToSchema, resolveModelConfiguration } from './chatModelConfigurationLogic.js'; -import { IModelConfigurationAccess } from './modelPicker/modelPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPicker/modelPickerModelConfig.js'; /** * Per-editor store for model configuration (e.g. context size, thinking effort). diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index ff9dc8e50ef6a4..0f92c8c2a1174f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -60,8 +60,10 @@ color: var(--vscode-foreground); } +/* Sized by its content: growing would pad the chip with empty space beside a short + * model name. It still shrinks, which is what ellipsises a long one. */ .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; } @@ -71,7 +73,7 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -105,6 +107,13 @@ justify-content: center; } +.chat-input-picker-item .action-label.model-picker-split .model-picker-config-summary { + flex: 0 0 auto; + margin-left: var(--vscode-spacing-size60); + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + .chat-input-picker-item .action-label.model-picker-split .model-picker-config { flex-shrink: 0; } @@ -403,3 +412,669 @@ .monaco-workbench .chat-model-hover-configurable-buttons > .monaco-button .codicon[class*='codicon-'] { font-size: var(--vscode-codiconFontSize-compact); } + +.action-widget.chat-model-picker-widget { + padding: var(--vscode-spacing-size80); +} + +.action-widget .tabbed-action-list-tabbar.chat-model-picker-tabbar { + align-items: center; + gap: var(--vscode-spacing-size20); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabstrip { + display: flex; + flex: 0 1 auto; + align-items: center; + min-width: 0; + gap: var(--vscode-spacing-size20); +} + +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabstrip { + display: none; +} + +.action-widget .chat-model-picker-tabbar .tabbed-action-list-filter-slot { + display: contents; +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + height: var(--vscode-spacing-size280); + padding: var(--vscode-spacing-sizeNone); + border: none; +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row { + flex: 1; + align-self: center; + min-width: 0; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-input, +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-input:focus { + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone); + border-color: transparent; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + box-shadow: none; +} + +/* The field replaces the tab strip and has no box of its own, so the global input focus + * chrome is dropped. Focus still has to be visible, so it is drawn as a ring instead. */ +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row input.action-list-filter-input:focus, +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row input.action-list-filter-input:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio { + gap: var(--vscode-spacing-size20); + width: auto; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action { + position: relative; + box-sizing: border-box; + flex: 0 0 auto; + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size280); + min-width: var(--vscode-spacing-size280); + padding: var(--vscode-spacing-sizeNone); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button > .codicon { + flex: 0 0 auto; + margin: var(--vscode-spacing-sizeNone); +} + +/* Let the active tab label yield space first, and clip the label rather than its underline. */ +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active { + flex: 0 1 auto; + width: auto; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:hover:not(.active), +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active, +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active:hover, +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabbar-action.checked { + color: var(--vscode-foreground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active::after, +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabbar-action.checked::after { + content: ""; + position: absolute; + right: var(--vscode-spacing-size40); + bottom: calc(-1 * var(--vscode-spacing-size60) - var(--vscode-strokeThickness)); + left: var(--vscode-spacing-size40); + height: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-textLink-foreground); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:focus-visible, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button .codicon, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action .codicon { + font-size: var(--vscode-codiconFontSize); +} + +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.separator { + opacity: 0.6; +} + +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action:hover, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action.focused, +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:hover, +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active { + opacity: 1; +} + +.action-widget .chat-model-picker-tabbed, +.action-widget .chat-model-picker-welcome { + padding: var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row { + box-sizing: border-box; + width: calc(100% - var(--vscode-spacing-size80)); + margin-left: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + border-radius: var(--vscode-cornerRadius-small); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action { + gap: 0; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .codicon:first-child { + display: flex; + flex: 0 0 var(--vscode-spacing-size200); + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size200); + height: 100%; + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +/* Override the shared separator rule so section headings have no rule line. */ +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator, +.action-widget .chat-model-picker-tabbed .monaco-list-row.group-header { + box-sizing: border-box; + width: calc(100% - var(--vscode-spacing-size80)); + margin: var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + border: none; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.group-header { + padding-left: var(--vscode-spacing-sizeNone); + padding-top: var(--vscode-spacing-size40); + color: var(--vscode-foreground); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.separator > span:not(:empty) { + padding-left: var(--vscode-spacing-sizeNone); +} + +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator:not(.has-label) { + display: flex; + align-items: center; + padding-top: var(--vscode-spacing-sizeNone); +} + +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator:not(.has-label)::after { + content: ''; + flex: 1 1 auto; + height: var(--vscode-strokeThickness); + background: var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-section-toggle { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-section-toggle > .codicon:first-child { + font-size: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.chat-model-picker-section-toggle .title { + flex: 0 1 auto; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.chat-model-picker-section-toggle .action-item-badge { + margin-left: var(--vscode-spacing-size60); + margin-right: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked .title { + font-weight: var(--vscode-fontWeight-semiBold); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked > .codicon:first-child { + color: var(--vscode-button-background); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .actions-container { + gap: var(--vscode-spacing-size20); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 20px; + height: 20px; + padding: var(--vscode-spacing-sizeNone); + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label.codicon { + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action .action-list-submenu-indicator.has-submenu { + visibility: hidden; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action:hover .action-list-submenu-indicator.has-submenu, +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.focused .action-list-submenu-indicator.has-submenu { + visibility: visible; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action .action-item-badge { + flex: 0 0 auto; + max-width: 96px; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone); + overflow: hidden; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: transparent; + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Align the smaller badge text by baseline; keep icons and chevrons centered. */ +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .title, +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .action-item-badge { + align-self: baseline; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description { + flex: 0 0 auto; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + white-space: nowrap; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable.has-link .description { + padding: var(--vscode-spacing-sizeNone); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a, +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:visited { + display: inline-block; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-textLink-foreground); + background: color-mix(in srgb, var(--vscode-textLink-foreground) 14%, transparent); + text-decoration: none; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:hover, +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:active { + color: var(--vscode-textLink-activeForeground); + background: color-mix(in srgb, var(--vscode-textLink-activeForeground) 22%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-badge-promo .action-item-badge { + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + color: var(--vscode-charts-green, var(--vscode-textLink-foreground)); + background: color-mix(in srgb, var(--vscode-charts-green, var(--vscode-textLink-foreground)) 14%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-badge-warning .action-item-badge { + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + color: var(--vscode-editorWarning-foreground); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 14%, transparent); +} + +.chat-model-picker-auto-row { + display: flex; + flex-direction: column; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80) var(--vscode-spacing-sizeNone); + border-top: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.chat-model-picker-auto-main { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + min-height: var(--vscode-spacing-size240); + cursor: pointer; +} + +.chat-model-picker-auto-label { + flex: 0 0 auto; + font-weight: var(--vscode-fontWeight-semiBold); +} + +/* Pinned right on its own line, so revealing the tiers below cannot move it. */ +.chat-model-picker-auto-row .monaco-switch { + margin-left: auto; +} + +.chat-model-picker-auto-tiers:not(:empty) { + margin-bottom: var(--vscode-spacing-size20); +} + +.chat-model-picker-auto-description { + margin-bottom: var(--vscode-spacing-size20); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-picker-auto-description.hidden { + display: none; +} + +.monaco-reduce-motion .chat-model-picker-auto-control { + transition: none; +} + +.chat-model-picker-welcome { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-size320) var(--vscode-spacing-size160); + /* Holds the popup near its list height so switching destinations doesn't jump. */ + min-height: 200px; + text-align: center; +} + +.chat-model-picker-welcome-provider { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-picker-welcome-icon.codicon { + font-size: 32px; + color: var(--vscode-descriptionForeground); +} + +.chat-model-picker-welcome-title { + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-descriptionForeground); +} + +.chat-model-picker-welcome-message { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-picker-welcome .monaco-button { + width: auto; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size240); +} + +.chat-model-card { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); + padding: var(--vscode-spacing-size100); + min-width: 220px; + max-width: 300px; + font-size: var(--vscode-fontSize-label1); +} + +.action-list-submenu-panel.chat-model-card-panel { + border-radius: var(--vscode-cornerRadius-large); + background: var(--vscode-menu-background); + box-shadow: var(--vscode-shadow-lg); + overflow: hidden; +} + +.chat-model-card.action-list-submenu-hover-header { + padding: var(--vscode-spacing-size120); + max-width: 300px; +} + +.chat-model-card-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-name { + flex: 0 1 auto; + min-width: 0; +} + +.chat-model-card-name { + font-weight: var(--vscode-fontWeight-semiBold); + font-size: var(--vscode-fontSize-heading3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Pinning sits with the model's name rather than on its row: a control that only + * appears on hover makes the list twitch as the pointer crosses it. */ +.chat-model-card-pin { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; + cursor: pointer; +} + +.chat-model-card-pin:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-model-card-pin.checked { + color: var(--vscode-textLink-foreground); +} + +.chat-model-card-pin:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-model-card-pin .codicon { + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-model-card-badge { + flex: 0 0 auto; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + white-space: nowrap; +} + +.chat-model-card-badge.high-cost { + color: var(--vscode-editorWarning-foreground); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 14%, transparent); +} + +.chat-model-card-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); +} + +.chat-model-card-section-heading { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-section-title { + flex: 1 1 auto; + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-model-card-section-value { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-options { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); +} + +.chat-model-card-option { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-small); + cursor: pointer; +} + +.chat-model-card-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.chat-model-card-option.checked { + background-color: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-model-card-option.checked .chat-model-card-option-check { + color: var(--vscode-button-background); +} + +.chat-model-card-option:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-model-card-option-check { + flex: 0 0 auto; + width: var(--vscode-spacing-size160); + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-model-card-option-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-model-card-pricing-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vscode-spacing-size40); + width: 100%; + padding: var(--vscode-spacing-sizeNone); + border: none; + color: inherit; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-model-card-pricing-chevron { + flex: 0 0 auto; + font-size: var(--vscode-codiconFontSize-compact); + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-pricing-toggle:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small); +} + +.chat-model-card-pricing-body { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); + margin-top: var(--vscode-spacing-size40); +} + +.chat-model-card-pricing-caption { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-card-pricing-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-pricing-label { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-pricing-value { + font-weight: var(--vscode-fontWeight-semiBold); + font-variant-numeric: tabular-nums; +} + +.chat-model-card-description { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-description p { + margin: 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 6f39c59144fab7..1e467590233a65 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -9,8 +9,6 @@ import { getBaseLayerHoverDelegate } from '../../../../../../../base/browser/ui/ import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem } from '../../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../../../base/common/actions.js'; -import { IStringDictionary } from '../../../../../../../base/common/collections.js'; -import { Event } from '../../../../../../../base/common/event.js'; import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; @@ -20,26 +18,9 @@ import { IKeybindingService } from '../../../../../../../platform/keybinding/com import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { IChatInputPickerOptions } from '../chatInputPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPickerModelConfig.js'; import { ModelPickerWidget } from './modelPickerWidget.js'; -/** - * Read/write access to a model's configuration (e.g. context size, thinking - * effort). Implemented either by the global {@link ILanguageModelsService} or by - * a per-editor override layer so that one editor's changes do not sync to other - * already-open editors. Structurally satisfied by `ILanguageModelsService`. - */ -export interface IModelConfigurationAccess { - getModelConfiguration(modelId: string): IStringDictionary | undefined; - setModelConfiguration(modelId: string, values: IStringDictionary): Promise; - getModelConfigurationActions(modelId: string): IAction[]; - /** - * Fires when this access layer's configuration changes (e.g. user picks a - * new context size). Implementations that always read the global value can - * omit this and rely on `ILanguageModelsService.onDidChangeLanguageModels`. - */ - readonly onDidChange?: Event; -} - export interface IModelPickerPresentationOptions { readonly useGroupedModelPicker: boolean; readonly showManageModelsAction: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts new file mode 100644 index 00000000000000..6ab3cf565313a3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Radio } from '../../../../../../../base/browser/ui/radio/radio.js'; +import { Switch } from '../../../../../../../base/browser/ui/toggle/switch.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../../nls.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { getModelConfigProperty, getModelConfigValueLabel, IModelConfigurationAccess, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; + +export interface IAutoRowOptions { + readonly autoModel: ILanguageModelChatMetadataAndIdentifier; + readonly configurationAccess: IModelConfigurationAccess; + readonly isEnabled: () => boolean; + readonly onToggle: (enabled: boolean) => void; +} + +/** + * The Auto row pinned below the model list: a switch that turns automatic model + * selection on, and the tiers it routes by. + * + * The switch keeps one position whether or not the tiers are showing, so turning Auto + * on does not move the control the user just aimed at. + */ +export class ModelPickerAutoRow extends DisposableStore { + + readonly element = dom.$('.chat-model-picker-auto-row'); + + private readonly _renderDisposables = this.add(new DisposableStore()); + private readonly _toggle: Switch; + private readonly _tierContainer: HTMLElement; + private readonly _description: HTMLElement; + private _tierControl: Radio | undefined; + + constructor(private readonly _options: IAutoRowOptions) { + super(); + + const main = dom.append(this.element, dom.$('.chat-model-picker-auto-main')); + dom.append(main, dom.$('.chat-model-picker-auto-label', undefined, _options.autoModel.metadata.name)); + + this._toggle = this.add(new Switch({ + ariaLabel: localize('chat.modelPicker.autoToggle', "Choose a model automatically"), + checked: _options.isEnabled(), + })); + main.appendChild(this._toggle.domNode); + this.add(this._toggle.onChange(checked => _options.onToggle(checked))); + + // The label and the gap beside it flip the switch too, the way a row carrying a + // standalone toggle does. The switch stops its own clicks from reaching here. + this.add(dom.addDisposableListener(main, dom.EventType.CLICK, () => { + if (this._toggle.disabled) { + return; + } + this._toggle.checked = !this._toggle.checked; + _options.onToggle(this._toggle.checked); + })); + // Pressing the strip must not move focus out of the list, which would blur the + // popup and dismiss it before the click lands. + this.add(dom.addDisposableGenericMouseDownListener(main, e => e.preventDefault())); + + this._tierContainer = dom.append(this.element, dom.$('.chat-model-picker-auto-tiers')); + this._description = dom.append(this.element, dom.$('.chat-model-picker-auto-description')); + // The description is inert text, so pressing it must not dismiss the popup either. + // The tiers are left alone: their buttons take focus of their own accord. + this.add(dom.addDisposableGenericMouseDownListener(this._description, e => e.preventDefault())); + this.render(); + } + + /** Re-reads the selection and tier so the row matches the current state. */ + render(): void { + const enabled = this._options.isEnabled(); + this.element.classList.toggle('enabled', enabled); + this._toggle.checked = enabled; + + const tier = getModelConfigProperty(this._options.autoModel, this._options.configurationAccess, MODEL_CONFIG_GROUP_EFFORT); + const values = tier?.schema.enum ?? []; + dom.clearNode(this._tierContainer); + this._renderDisposables.clear(); + this._tierControl = undefined; + + if (enabled && tier && values.length > 1) { + const control = this._renderDisposables.add(new Radio({ + ariaLabel: tier.schema.title ?? localize('chat.modelPicker.autoTier', "Optimize for"), + className: 'segmented', + arrowKeyBehavior: 'focus', + items: values.map((value, index) => ({ + text: getModelConfigValueLabel(tier.schema, value), + tooltip: tier.schema.enumDescriptions?.[index], + isActive: value === tier.value, + })), + })); + this._renderDisposables.add(control.onDidSelect(async index => { + await this._options.configurationAccess.setModelConfiguration(this._options.autoModel.identifier, { [tier.key]: values[index] }); + this.render(); + // Rebuilt, so focus has to land on the control that replaced this one. + this._tierControl?.focusActiveItem(); + })); + this._tierContainer.appendChild(control.domNode); + this._tierControl = control; + } + + const selectedIndex = values.indexOf(tier?.value); + const tierDescription = enabled && selectedIndex >= 0 ? tier?.schema.enumDescriptions?.[selectedIndex] : undefined; + // Auto's own detail stays put; the tier description joins it rather than replacing it. + const detail = this._options.autoModel.metadata.detail; + const parts = [detail, tierDescription].filter(part => !!part); + this._description.textContent = parts.join(' · '); + this._description.classList.toggle('hidden', parts.length === 0); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts new file mode 100644 index 00000000000000..4ddc25631b9668 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../../../nls.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { getModelConfigSummary, IModelConfigurationAccess } from './modelPickerModelConfig.js'; + +/** Color treatment for a model-row badge. */ +export const enum ModelBadgeTone { + /** Plain text, no fill. The default for descriptive labels like a provider name. */ + Neutral = 'neutral', + /** A quiet tint, for a state the user chose, e.g. the current configuration. */ + Selected = 'selected', + /** Warm, for an offer the user gains from. */ + Promo = 'promo', + /** Warning, for a model going away or carrying a caveat. */ + Warning = 'warning', +} + +export interface IModelBadge { + readonly text: string; + readonly tone: ModelBadgeTone; +} + +/** Warning categories that mean the model itself is going away. */ +const DEPRECATION_WARNING_CODES: ReadonlySet = new Set(['model_pending_deprecation', 'model_deprecated']); + +export interface IModelBadgeContext { + readonly configurationAccess: IModelConfigurationAccess; + /** The provider a model came from, when the list does not already group by it. */ + readonly providerLabel?: string; +} + +/** + * The single badge a model row shows. A row has one badge slot, so the states are + * ranked by how much the user needs to know before picking. + */ +export function getModelBadge( + model: ILanguageModelChatMetadataAndIdentifier, + context: IModelBadgeContext, +): IModelBadge | undefined { + if (isDeprecated(model)) { + return { text: localize('chat.modelPicker.badge.deprecated', "Retiring"), tone: ModelBadgeTone.Warning }; + } + const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined; + if (promo) { + return { text: localize('chat.modelPicker.badge.promo', "{0}% off", promo.discountPercent), tone: ModelBadgeTone.Promo }; + } + // Any model the user tuned says so, not just the selected one, so a row that will + // behave differently from its defaults is recognisable before it is picked. + const summary = getModelConfigSummary(model, context.configurationAccess); + if (summary) { + return { text: summary, tone: ModelBadgeTone.Selected }; + } + return context.providerLabel ? { text: context.providerLabel, tone: ModelBadgeTone.Neutral } : undefined; +} + +/** Whether the model is retiring, which its provider reports as a warning category. */ +export function isDeprecated(model: ILanguageModelChatMetadataAndIdentifier): boolean { + return Object.keys(model.metadata.warningText ?? {}).some(code => DEPRECATION_WARNING_CODES.has(code)); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts new file mode 100644 index 00000000000000..0438c74edf0b62 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Radio } from '../../../../../../../base/browser/ui/radio/radio.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { formatModelCost, getCreditsPerMillionTokensLabel, getMaxContextLabel, getModelContextWindowTotal, getModelCostMetrics, renderModelDescription } from './modelPickerDetails.js'; +import { createMessageBanner } from './modelPickerHover.js'; +import { getModelConfigProperty, getModelConfigValueLabel, IModelConfigProperty, IModelConfigurationAccess, isExtendedContext, MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { getCategoryLabel, getPriceCategoryLabel, isAutoModel, isHighCostCategory, isMultiplierPricing } from './modelPickerPresentation.js'; +import { IModelSpeedVariants } from './modelPickerVariants.js'; + +/** + * Whether the pricing breakdown is open, shared by every card. Most people never need + * the numbers, and the ones who do should not have to open them on each model. + */ +export interface IPricingDisclosure { + isExpanded(): boolean; + setExpanded(expanded: boolean): void; + /** Fires when the state changes, so cards already built stay in step. */ + readonly onDidChange: Event; +} + +export interface IModelCardOptions { + readonly model: ILanguageModelChatMetadataAndIdentifier; + readonly configurationAccess: IModelConfigurationAccess; + /** Whether the account is billed by credits, which is when cost numbers are shown. */ + readonly isUBB: boolean; + readonly openerService: IOpenerService; + /** Called after a configuration value changes so the caller can report it and refresh its own label. */ + readonly onDidChangeConfiguration?: (group: string, key: string, fromValue: unknown, toValue: unknown) => void; + /** Whether the model is pinned, when pinning is offered here. */ + readonly isPinned?: boolean; + readonly onTogglePin?: (pinned: boolean) => void; + readonly pricingDisclosure?: IPricingDisclosure; + /** The faster twin of this model, when the provider offers one. */ + readonly speedVariants?: IModelSpeedVariants; + /** Called with the twin the user picked, which becomes the selected model. */ + readonly onSelectVariant?: (model: ILanguageModelChatMetadataAndIdentifier) => void; +} + +/** + * The detail card shown beside a model row: what the model costs, how hard it + * thinks, and how much context it gets. Configuration changes are written + * straight through and the card re-renders itself in place. + */ +export class ModelCard extends DisposableStore { + + readonly element = dom.$('.chat-model-card'); + + private readonly _contentDisposables = this.add(new DisposableStore()); + /** The pricing disclosure's button, rebuilt with the rest of the card on each render. */ + private _pricingToggle: HTMLElement | undefined; + + constructor(private readonly _options: IModelCardOptions) { + super(); + if (_options.pricingDisclosure) { + // Opening the breakdown on one model opens it on the rest, so cards built + // earlier are re-rendered rather than left showing the old state. + this.add(_options.pricingDisclosure.onDidChange(() => this._render())); + } + this._render(); + } + + private _configProperty(group: string): IModelConfigProperty | undefined { + return getModelConfigProperty(this._options.model, this._options.configurationAccess, group); + } + + private async _setValue(group: string, key: string, value: unknown): Promise { + const previous = this._configProperty(group)?.value; + await this._options.configurationAccess.setModelConfiguration(this._options.model.identifier, { [key]: value }); + this._render(); + this._options.onDidChangeConfiguration?.(group, key, previous, value); + } + + private _render(): void { + this._contentDisposables.clear(); + dom.clearNode(this.element); + this._pricingToggle = undefined; + + const { model, isUBB, openerService } = this._options; + const metadata = model.metadata; + const isAuto = isAutoModel(model); + + this._renderHeader(); + + if (!isAuto) { + for (const message of Object.values(metadata.warningText ?? {})) { + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-warning-text', Codicon.warningCompact, this._contentDisposables, openerService)); + } + for (const message of Object.values(metadata.infoText ?? {})) { + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-info-text', Codicon.info, this._contentDisposables, openerService)); + } + } + const promo = !isAuto && ILanguageModelChatMetadata.hasPromoDiscount(metadata) ? metadata.promo : undefined; + if (promo) { + const endsAtLabel = ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt); + const message = endsAtLabel ? `${promo.message} ${endsAtLabel}` : promo.message; + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-promo-text', Codicon.info, this._contentDisposables, openerService)); + } + + const effort = this._configProperty(MODEL_CONFIG_GROUP_EFFORT); + const context = this._configProperty(MODEL_CONFIG_GROUP_CONTEXT); + + if (effort) { + this._renderEffortSection(effort, isAuto); + } + if (context) { + this._renderContextSection(context); + } else if (!isAuto) { + this._renderContextWindow(metadata); + } + // After the settings every model has, so those keep one position whether or not + // this model happens to have a faster twin. + if (!isAuto) { + this._renderSpeedSection(); + } + if (!isAuto && isUBB) { + this._renderCost(context); + } else if (!isAuto && metadata.pricing && isMultiplierPricing(model)) { + this._renderSection(localize('models.cost', "Cost: {0}", metadata.pricing)); + } + if (!this.element.firstChild && metadata.tooltip) { + this._renderDescription(metadata.tooltip); + } + } + + private _renderHeader(): void { + const metadata = this._options.model.metadata; + const isAuto = isAutoModel(this._options.model); + const header = dom.append(this.element, dom.$('.chat-model-card-header')); + dom.append(header, dom.$('.chat-model-card-name', undefined, metadata.name)); + + const badgeLabel = isAuto + ? metadata.detail + : getPriceCategoryLabel(metadata.priceCategory) ?? getCategoryLabel(metadata.category); + if (badgeLabel) { + const badge = dom.append(header, dom.$('span.chat-model-card-badge', undefined, badgeLabel)); + badge.classList.toggle('high-cost', !isAuto && isHighCostCategory(metadata.priceCategory)); + } + + // Pinning lives here rather than on the row: a control that only exists on hover + // makes every row twitch as the pointer crosses the list. + if (this._options.onTogglePin) { + const pinned = !!this._options.isPinned; + const label = pinned + ? localize('chat.modelPicker.unpin', "Unpin Model") + : localize('chat.modelPicker.pin', "Pin Model"); + const button = dom.append(header, dom.$('button.chat-model-card-pin')); + button.type = 'button'; + button.classList.toggle('checked', pinned); + button.setAttribute('aria-pressed', String(pinned)); + button.ariaLabel = label; + button.title = label; + dom.append(button, dom.$(`span${ThemeIcon.asCSSSelector(pinned ? Codicon.pinned : Codicon.pin)}`)); + this._contentDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this._options.onTogglePin?.(!pinned); + })); + } + } + + private _renderDescription(tooltip: string): void { + const element = renderModelDescription(tooltip, this._options.openerService, this._contentDisposables); + element.classList.add('chat-model-card-description'); + this.element.appendChild(element); + } + + private _renderSection(title: string): HTMLElement { + const section = dom.append(this.element, dom.$('.chat-model-card-section')); + const heading = dom.append(section, dom.$('.chat-model-card-section-heading')); + dom.append(heading, dom.$('.chat-model-card-section-title', undefined, title)); + return section; + } + + private _renderEffortSection(effort: IModelConfigProperty, isAuto: boolean): void { + this._renderChoiceSection(effort, MODEL_CONFIG_GROUP_EFFORT, effort.schema.title ?? (isAuto + ? localize('models.routingProfile', "Routing Profile") + : localize('chat.effort.header', "Thinking Effort"))); + } + + /** + * The context windows the model can be given. Rendered like every other setting + * whether the producer offers two or five: a switch would read as off/on, but + * neither window is "off", and it would hide the one being chosen between. + */ + private _renderContextSection(context: IModelConfigProperty): void { + this._renderChoiceSection(context, MODEL_CONFIG_GROUP_CONTEXT, context.schema.title ?? localize('chat.context.header', "Context")); + } + + /** + * One setting: its name and the choices. The value is not described above the + * control, since these are ordered scales whose labels already say what they mean. + */ + private _renderChoiceSection(property: IModelConfigProperty, group: string, title: string): void { + const values = property.schema.enum ?? []; + const section = this._renderSection(title); + const control = this._contentDisposables.add(new Radio({ + ariaLabel: title, + className: 'segmented', + // Selecting closes the picker, so arrows must be able to travel past an option. + arrowKeyBehavior: 'focus', + items: values.map((value, index) => ({ + text: getModelConfigValueLabel(property.schema, value), + tooltip: property.schema.enumDescriptions?.[index], + isActive: value === property.value, + })), + })); + this._contentDisposables.add(control.onDidSelect(index => void this._setValue(group, property.key, values[index]))); + section.appendChild(control.domNode); + } + + /** + * The two speeds the provider offers the same model at. Picking one selects that + * model, since the twins are separate models with their own prices. + */ + private _renderSpeedSection(): void { + const variants = this._options.speedVariants; + if (!variants) { + return; + } + const choices = [ + { label: localize('models.speed.standard', "Standard"), model: variants.standard }, + { label: localize('models.speed.fast', "Fast"), model: variants.fast }, + ]; + const title = localize('models.speed', "Speed"); + const section = this._renderSection(title); + const control = this._contentDisposables.add(new Radio({ + ariaLabel: title, + className: 'segmented', + arrowKeyBehavior: 'focus', + items: choices.map(choice => ({ + text: choice.label, + isActive: choice.model.identifier === this._options.model.identifier, + })), + })); + this._contentDisposables.add(control.onDidSelect(index => { + const next = choices[index].model; + if (next.identifier !== this._options.model.identifier) { + this._options.onSelectVariant?.(next); + } + })); + section.appendChild(control.domNode); + } + + private _renderContextWindow(metadata: ILanguageModelChatMetadata): void { + const total = getModelContextWindowTotal(metadata); + if (!total) { + return; + } + const section = dom.append(this.element, dom.$('.chat-model-card-section')); + const heading = dom.append(section, dom.$('.chat-model-card-section-heading')); + dom.append(heading, dom.$('.chat-model-card-section-title', undefined, getMaxContextLabel())); + dom.append(heading, dom.$('.chat-model-card-section-value', undefined, formatTokenCount(total))); + } + + private _renderCost(context: IModelConfigProperty | undefined): void { + const metadata = this._options.model.metadata; + const metrics = getModelCostMetrics(metadata); + if (!metrics.length) { + if (metadata.pricing) { + this._renderSection(localize('models.cost', "Cost: {0}", metadata.pricing)); + } + return; + } + + const useExtended = !!context && isExtendedContext(context); + const disclosure = this._options.pricingDisclosure; + const expanded = disclosure ? disclosure.isExpanded() : true; + const section = dom.append(this.element, dom.$('.chat-model-card-section.chat-model-card-pricing')); + const bodyId = `chat-model-card-pricing-${this._options.model.identifier.replace(/[^\w-]/g, '-')}`; + + // Folded away by default: the numbers only matter to the people who go looking + // for them, and they are the last thing most people need to read. + if (disclosure) { + const title = localize('models.pricingDetails', "Pricing details"); + const toggle = dom.append(section, dom.$('button.chat-model-card-pricing-toggle')); + toggle.type = 'button'; + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.setAttribute('aria-controls', bodyId); + dom.append(toggle, dom.$('span.chat-model-card-section-title', undefined, title)); + dom.append(toggle, dom.$(`span.chat-model-card-pricing-chevron${ThemeIcon.asCSSSelector(expanded ? Codicon.chevronDown : Codicon.chevronRight)}`)); + this._pricingToggle = toggle; + this._contentDisposables.add(dom.addDisposableListener(toggle, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + const hadFocus = dom.isActiveElement(toggle); + disclosure.setExpanded(!expanded); + // The click rebuilt this card, so focus has to land on the button that + // replaced the one that was pressed. + if (hadFocus) { + this._pricingToggle?.focus(); + } + })); + } + if (!expanded) { + return; + } + + const body = dom.append(section, dom.$('.chat-model-card-pricing-body')); + body.id = bodyId; + // The unit is stated once, so each row can be read as a plain name and number. + dom.append(body, dom.$('.chat-model-card-pricing-caption', undefined, getCreditsPerMillionTokensLabel())); + for (const metric of metrics) { + const cost = useExtended ? metric.extended ?? metric.standard : metric.standard; + const row = dom.append(body, dom.$('.chat-model-card-pricing-row')); + dom.append(row, dom.$('span.chat-model-card-pricing-label', undefined, metric.label)); + dom.append(row, dom.$('span.chat-model-card-pricing-value', undefined, formatModelCost(cost))); + } + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts index e281b1da780185..4f044c20ca2df4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts @@ -12,40 +12,10 @@ import { ActionListItemKind, IActionListHeaderLink, IActionListItem } from '../. import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; -import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; -import { IModelConfigurationAccess } from './modelPickerActionItem.js'; - -type ChatThinkingEffortChangeClassification = { - owner: 'lramos15'; - comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed'; - model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the configuration was changed for' }; - property: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The first-party configuration property that was changed (reasoningEffort, or tier for the Auto model); "unknown" for third-party providers, which choose their own keys' }; - fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous value of the configuration property' }; - toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new value of the configuration property' }; -}; - -type ChatThinkingEffortChangeEvent = { - model: string | TelemetryTrustedValue; - property: string; - fromValue: string; - toValue: string; -}; - -type ChatContextSizeChangeClassification = { - owner: 'lramos15'; - comment: 'Reporting when the context window size is changed'; - model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the context size was changed for' }; - fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous context size value' }; - toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new context size value' }; -}; - -type ChatContextSizeChangeEvent = { - model: string | TelemetryTrustedValue; - fromValue: string; - toValue: string; -}; +import { getModelConfigProperty, IModelConfigurationAccess, MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { logModelConfigurationChange } from './modelPickerTelemetry.js'; export interface IModelPickerConfigurationHost { readonly getSelectedModel: () => ILanguageModelChatMetadataAndIdentifier | undefined; @@ -66,8 +36,8 @@ export class ModelPickerConfiguration { renderButton(button: HTMLElement, compact: boolean, noModelsAvailable: boolean): void { const model = this._host.getSelectedModel(); - const effortConfig = this._getConfigProperty('navigation'); - const tokensConfig = this._getConfigProperty('tokens'); + const effortConfig = this._getConfigProperty(MODEL_CONFIG_GROUP_EFFORT); + const tokensConfig = this._getConfigProperty(MODEL_CONFIG_GROUP_CONTEXT); if (compact || !model || noModelsAvailable || (!effortConfig && !tokensConfig)) { button.style.display = 'none'; return; @@ -168,23 +138,7 @@ export class ModelPickerConfiguration { } private _getConfigProperty(group: string) { - const model = this._host.getSelectedModel(); - if (!model) { - return undefined; - } - const schema = model.metadata.configurationSchema; - if (!schema?.properties) { - return undefined; - } - const configurationAccess = this._host.getConfigurationAccess(); - const currentConfig = configurationAccess.getModelConfiguration(model.identifier) ?? {}; - for (const [key, propSchema] of Object.entries(schema.properties)) { - if (propSchema.group !== group || !propSchema.enum?.length) { - continue; - } - return { key, value: currentConfig[key] ?? propSchema.default, schema: propSchema }; - } - return undefined; + return getModelConfigProperty(this._host.getSelectedModel(), this._host.getConfigurationAccess(), group); } private _buildItems(): IActionListItem[] { @@ -201,7 +155,6 @@ export class ModelPickerConfiguration { group: string, fallbackHeaderLabel: string, formatValueLabel: (value: unknown, enumLabel: string | undefined) => string, - logChange: (value: unknown, previousValue: string, key: string) => void, ): void => { const config = this._getConfigProperty(group); if (!config) { @@ -229,7 +182,7 @@ export class ModelPickerConfiguration { tooltip: enumDescription ?? '', label: displayLabel, run: () => { - logChange(value, previousValue, config.key); + logModelConfigurationChange(this._telemetryService, model, group, config.key, previousValue, value); return configurationAccess.setModelConfiguration(modelIdentifier, { [config.key]: value }); } }, @@ -246,27 +199,14 @@ export class ModelPickerConfiguration { }; appendConfigSection( - 'navigation', + MODEL_CONFIG_GROUP_EFFORT, localize('chat.effort.header', "Thinking Effort"), (value, enumLabel) => enumLabel ?? String(value), - (value, previousValue, key) => this._telemetryService.publicLog2('chat.thinkingEffortChange', { - model: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(modelIdentifier) : 'unknown', - // Third-party providers choose their own property keys, so only - // first-party ones are reported as a controlled vocabulary. - property: model.metadata.vendor === 'copilot' ? key : 'unknown', - fromValue: previousValue, - toValue: String(value), - }), ); appendConfigSection( - 'tokens', + MODEL_CONFIG_GROUP_CONTEXT, localize('chat.tokens.header', "Context Size"), (value, enumLabel) => enumLabel ?? formatTokenCount(Number(value)), - (value, previousValue) => this._telemetryService.publicLog2('chat.contextSizeChange', { - model: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(modelIdentifier) : 'unknown', - fromValue: previousValue, - toValue: String(value), - }), ); return items; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts new file mode 100644 index 00000000000000..8f98d9a79d9964 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderMarkdown } from '../../../../../../../base/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { ILanguageModelChatMetadata } from '../../../../common/languageModels.js'; + +/** One cost metric, with the value for each context tier. */ +export interface IModelCostMetric { + readonly label: string; + readonly standard: number | null | undefined; + readonly extended: number | null | undefined; +} + +/** The cost metrics a model reports, in the order they are shown. */ +export function getModelCostMetrics(metadata: ILanguageModelChatMetadata): IModelCostMetric[] { + return [ + { label: localize('models.inputCostLabel', "Input"), standard: metadata.inputCost, extended: metadata.longContextInputCost }, + { label: localize('models.outputCostLabel', "Output"), standard: metadata.outputCost, extended: metadata.longContextOutputCost }, + { label: localize('models.cacheCostLabel', "Cache Read"), standard: metadata.cacheCost, extended: metadata.longContextCacheCost }, + { label: localize('models.cacheWriteCostLabel', "Cache Write"), standard: metadata.cacheWriteCost, extended: metadata.longContextCacheWriteCost }, + ].filter(metric => metric.standard !== undefined || metric.extended !== undefined); +} + +export function formatModelCost(cost: number | null | undefined): string { + return typeof cost === 'number' ? String(cost) : localize('models.cost.unknown', "Unknown"); +} + +/** The unit the cost numbers are given in, stated once above them. */ +export function getCreditsPerMillionTokensLabel(): string { + return localize('models.creditsPerMillionTokens', "Credits per 1M tokens"); +} + +/** The context window a model offers, or 0 when it reports none. */ +export function getModelContextWindowTotal(metadata: ILanguageModelChatMetadata): number { + return (metadata.maxInputTokens ?? 0) + (metadata.maxOutputTokens ?? 0); +} + +export function getMaxContextLabel(): string { + return localize('models.contextSize', "Max context"); +} + +/** Renders a model's description markdown. The caller places and classes the element. */ +export function renderModelDescription(tooltip: string, openerService: IOpenerService, store: DisposableStore): HTMLElement { + const rendered = store.add(renderMarkdown(new MarkdownString(tooltip, { supportThemeIcons: true }), { + actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + return rendered.element; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index e093c4228e7cf2..8135977feb7832 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -18,9 +18,11 @@ import { localize } from '../../../../../../../nls.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; -import { getPriceCategoryLabel, isAutoModel, isMultiplierPricing } from './modelPickerPresentation.js'; +import { formatModelCost, getCreditsPerMillionTokensLabel, getMaxContextLabel, getModelContextWindowTotal, getModelCostMetrics, renderModelDescription } from './modelPickerDetails.js'; +import { MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { getCategoryLabel, getPriceCategoryLabel, isAutoModel, isHighCostCategory, isMultiplierPricing } from './modelPickerPresentation.js'; -const SUPPORTED_CONFIG_GROUPS: readonly string[] = ['navigation', 'tokens']; +const SUPPORTED_CONFIG_GROUPS: readonly string[] = [MODEL_CONFIG_GROUP_EFFORT, MODEL_CONFIG_GROUP_CONTEXT]; export interface IModelPickerHoverContent { readonly element: HTMLElement; @@ -84,15 +86,10 @@ export function getModelHoverContent( let costInfoRendered = false; let costTableRendered = false; if (!isAuto && isUBB) { - const metrics: { label: string; def: number | null | undefined; long: number | null | undefined }[] = [ - { label: localize('models.inputCostLabel', "Input"), def: model.metadata.inputCost, long: model.metadata.longContextInputCost }, - { label: localize('models.outputCostLabel', "Output"), def: model.metadata.outputCost, long: model.metadata.longContextOutputCost }, - { label: localize('models.cacheCostLabel', "Cache Read"), def: model.metadata.cacheCost, long: model.metadata.longContextCacheCost }, - { label: localize('models.cacheWriteCostLabel', "Cache Write"), def: model.metadata.cacheWriteCost, long: model.metadata.longContextCacheWriteCost }, - ].filter(metric => metric.def !== undefined || metric.long !== undefined); + const metrics = getModelCostMetrics(model.metadata); if (metrics.length > 0) { - const hasLongContext = metrics.some(metric => metric.long !== undefined); + const hasLongContext = metrics.some(metric => metric.extended !== undefined); const table = dom.$('.chat-model-hover-cost-table'); if (hasLongContext) { container.classList.add('has-long-context'); @@ -105,13 +102,12 @@ export function getModelHoverContent( return; } row.appendChild(dom.$('span.chat-model-hover-cost-value', undefined, - dom.$('span.chat-model-hover-cost-number', undefined, - typeof cost === 'number' ? String(cost) : localize('models.cost.unknown', "Unknown")), + dom.$('span.chat-model-hover-cost-number', undefined, formatModelCost(cost)), )); }; const headerRow = dom.$('.chat-model-hover-cost-row.header'); - headerRow.appendChild(dom.$('span.chat-model-hover-cost-heading', undefined, localize('models.creditsPerMillionTokens', "Credits Per 1M Tokens"))); + headerRow.appendChild(dom.$('span.chat-model-hover-cost-heading', undefined, getCreditsPerMillionTokensLabel())); if (hasLongContext) { headerRow.appendChild(dom.$('span.chat-model-hover-cost-value.subheader', undefined, localize('models.defaultContext', "Default"))); headerRow.appendChild(dom.$('span.chat-model-hover-cost-value.subheader', undefined, localize('models.longContext', "Long Context"))); @@ -125,9 +121,9 @@ export function getModelHoverContent( const labelCell = dom.$('.chat-model-hover-cost-label'); labelCell.appendChild(dom.$('span.chat-model-hover-cost-label-text', undefined, metric.label)); row.appendChild(labelCell); - appendValueCell(row, metric.def); + appendValueCell(row, metric.standard); if (hasLongContext) { - appendValueCell(row, metric.long); + appendValueCell(row, metric.extended); } table.appendChild(row); } @@ -145,18 +141,15 @@ export function getModelHoverContent( } if (!costInfoRendered && model.metadata.tooltip) { - const descriptionMd = new MarkdownString(model.metadata.tooltip, { supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(descriptionMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - rendered.element.classList.add('chat-model-hover-description'); - container.appendChild(rendered.element); + const element = renderModelDescription(model.metadata.tooltip, openerService, disposables); + element.classList.add('chat-model-hover-description'); + container.appendChild(element); } if (!isAuto && !costTableRendered && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { - const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); + const totalTokens = getModelContextWindowTotal(model.metadata); const contextSection = dom.$('.chat-model-hover-context'); - contextSection.appendChild(dom.$('.chat-model-hover-context-label', undefined, localize('models.contextSize', "Max context"))); + contextSection.appendChild(dom.$('.chat-model-hover-context-label', undefined, getMaxContextLabel())); contextSection.appendChild(dom.$('.chat-model-hover-context-value', undefined, formatTokenCount(totalTokens))); container.appendChild(contextSection); } @@ -169,7 +162,7 @@ export function getModelHoverContent( for (const propSchema of Object.values(model.metadata.configurationSchema.properties)) { if (propSchema.enum && propSchema.enum.length >= 2 && propSchema.group && SUPPORTED_CONFIG_GROUPS.includes(propSchema.group) && !seenGroups.has(propSchema.group)) { // Auto's navigation option is its routing tier; the menu keeps the producer's "Optimize for…" title. - const label = isAuto && propSchema.group === 'navigation' ? localize('models.routingProfile', "Routing Profile") : propSchema.title ?? propSchema.description; + const label = isAuto && propSchema.group === MODEL_CONFIG_GROUP_EFFORT ? localize('models.routingProfile', "Routing Profile") : propSchema.title ?? propSchema.description; if (label) { seenGroups.add(propSchema.group); configButtons.push({ group: propSchema.group, label }); @@ -201,7 +194,7 @@ export function getModelHoverContent( * Builds one bordered message banner (an icon plus a rendered markdown message) * for the warning, info and promo notices shown at the top of the hover. */ -function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { +export function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { const banner = dom.$(`.${className}`); banner.appendChild(renderIcon(icon)); const markdown = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); @@ -217,25 +210,3 @@ function appendCostSection(container: HTMLElement, pricing: string): void { costSection.appendChild(dom.$('span', undefined, localize('models.cost', "Cost: {0}", pricing))); container.appendChild(costSection); } - -function isHighCostCategory(priceCategory: string | undefined): boolean { - return priceCategory === 'high' || priceCategory === 'very_high'; -} - -function getCategoryLabel(category: string | undefined): string | undefined { - switch (category) { - case undefined: - case '': - return undefined; - case 'lightweight': - return localize('chat.category.lightweight', "Lightweight"); - case 'versatile': - return localize('chat.category.versatile', "Versatile"); - case 'powerful': - return localize('chat.category.powerful', "Powerful"); - default: - return typeof category === 'string' - ? category.charAt(0).toUpperCase() + category.slice(1) - : undefined; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts index ab53effbe91368..50b2fb8ae56081 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts @@ -253,7 +253,7 @@ export function createUnavailableModelItem( group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, disabled: true, hideIcon: false, - className: 'chat-model-picker-unavailable', + className: typeof description === 'string' ? 'chat-model-picker-unavailable' : 'chat-model-picker-unavailable has-link', section, hover: { content: hoverContent }, }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts index 90b19f830988cc..6de39b879c8fd4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts @@ -42,6 +42,10 @@ export function getModelPickerControlModels( for (const [id, entry] of Object.entries(tier)) { if (entry.featured && availableModelIds.has(id)) { controlModels[id] = { ...entry, exists: true }; + } else if (entry.demoted && !controlModels[id]) { + // A demotion holds whoever is signed in, so it is not filtered away with + // the curated list the way a recommendation is. + controlModels[id] = { ...entry, exists: false }; } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts new file mode 100644 index 00000000000000..e19dfbae90fe21 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; + +/** A version token in a model id, e.g. the `5.6` in `example-5.6-sol`. */ +const VERSION_TOKEN = /^v?(\d+(?:\.\d+)*)$/; + +/** Where a model sits in its product line. */ +export interface IModelLine { + /** The line the model belongs to, e.g. `example-sol` for `example-5.6-sol`. */ + readonly line: string; + /** The version within that line, most significant part first. */ + readonly version: readonly number[]; +} + +/** + * Splits a model id into its product line and version by taking out the one token + * that reads as a version, e.g. `example-5.6-sol` is `example-sol` at 5.6. An id + * with no version token is a line of its own. + */ +export function parseModelLine(id: string): IModelLine { + const rest: string[] = []; + let version: readonly number[] | undefined; + for (const token of id.split('-')) { + const match = !version ? VERSION_TOKEN.exec(token) : undefined; + if (match) { + version = match[1].split('.').map(Number); + } else { + rest.push(token); + } + } + return { line: rest.join('-'), version: version ?? [] }; +} + +/** + * Tokens marking an early-access build. These are held out of the shortlist rather + * than listed by name, and stay selectable further down. + */ +const EARLY_ACCESS_TOKENS: ReadonlySet = new Set(['eap', 'experimental']); + +/** Whether the id marks an early-access build, which never leads the list. */ +export function isEarlyAccessModel(id: string): boolean { + return id.split('-').some(token => EARLY_ACCESS_TOKENS.has(token)); +} + +/** Orders two versions, longer runs of equal parts counting as newer. */ +function compareVersions(left: readonly number[], right: readonly number[]): number { + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +/** + * The newest model of each product line, which is the shortlist the picker leads with. + * Deriving it means a new version surfaces itself without anyone editing a list. + * Grouped by vendor too, since two providers can ship the same line name. + */ +export function latestOfEachLine( + models: readonly ILanguageModelChatMetadataAndIdentifier[], +): ILanguageModelChatMetadataAndIdentifier[] { + const newest = new Map(); + for (const model of models) { + const { line, version } = parseModelLine(model.metadata.id); + const key = `${model.metadata.vendor}/${line}`; + const current = newest.get(key); + if (!current || compareVersions(version, current.version) > 0) { + newest.set(key, { model, version }); + } + } + return [...newest.values()].map(entry => entry.model); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts new file mode 100644 index 00000000000000..038547158b8067 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../../../../base/common/actions.js'; +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelConfigurationSchema } from '../../../../common/languageModels.js'; + +/** + * Read/write access to a model's configuration (e.g. context size, thinking + * effort). Implemented either by the global `ILanguageModelsService` or by + * a per-editor override layer so that one editor's changes do not sync to other + * already-open editors. Structurally satisfied by `ILanguageModelsService`. + */ +export interface IModelConfigurationAccess { + getModelConfiguration(modelId: string): IStringDictionary | undefined; + setModelConfiguration(modelId: string, values: IStringDictionary): Promise; + getModelConfigurationActions(modelId: string): IAction[]; + /** + * Fires when this access layer's configuration changes (e.g. user picks a + * new context size). Implementations that always read the global value can + * omit this and rely on `ILanguageModelsService.onDidChangeLanguageModels`. + */ + readonly onDidChange?: Event; +} + +/** The thinking effort group, or the routing tier for the Auto model. */ +export const MODEL_CONFIG_GROUP_EFFORT = 'navigation'; +/** The context window group: how much context the model is given. */ +export const MODEL_CONFIG_GROUP_CONTEXT = 'tokens'; + +export type IModelConfigPropertySchema = NonNullable[string]; + +/** One configurable property of a model, with the value currently in effect. */ +export interface IModelConfigProperty { + readonly key: string; + readonly value: unknown; + readonly schema: IModelConfigPropertySchema; +} + +/** + * The first property of a model's configuration schema belonging to `group` that + * offers a choice, with the user's value or the schema default. + */ +export function getModelConfigProperty( + model: ILanguageModelChatMetadataAndIdentifier | undefined, + configurationAccess: IModelConfigurationAccess, + group: string, +): IModelConfigProperty | undefined { + const properties = model?.metadata.configurationSchema?.properties; + if (!properties) { + return undefined; + } + const currentConfig = configurationAccess.getModelConfiguration(model.identifier) ?? {}; + for (const [key, schema] of Object.entries(properties)) { + if (schema.group !== group || !schema.enum?.length) { + continue; + } + return { key, value: currentConfig[key] ?? schema.default, schema }; + } + return undefined; +} + +/** The label an enum value is shown with, falling back to a formatted raw value. */ +export function getModelConfigValueLabel(schema: IModelConfigPropertySchema, value: unknown): string { + const index = schema.enum?.indexOf(value) ?? -1; + const label = index >= 0 ? schema.enumItemLabels?.[index] : undefined; + return label ?? (typeof value === 'number' ? formatTokenCount(value) : String(value)); +} + +/** + * Whether the context property is set to its largest value. Producers order the + * context enum from smallest window to largest, so the last entry is the + * extended one. + */ +export function isExtendedContext(property: IModelConfigProperty): boolean { + const values = property.schema.enum ?? []; + return values.length > 1 && property.value === values[values.length - 1]; +} + +/** + * A short read-out of the model settings the user changed, e.g. "Extra high · 1M". + * + * Only values that differ from the model's own defaults are named: a model left alone + * has nothing to report, so the read-out marks the models that were deliberately tuned + * rather than restating a default on every row. + */ +export function getModelConfigSummary( + model: ILanguageModelChatMetadataAndIdentifier | undefined, + configurationAccess: IModelConfigurationAccess, +): string | undefined { + const parts: string[] = []; + for (const group of [MODEL_CONFIG_GROUP_EFFORT, MODEL_CONFIG_GROUP_CONTEXT]) { + const property = getModelConfigProperty(model, configurationAccess, group); + if (!property || property.value === undefined || property.value === property.schema.default) { + continue; + } + parts.push(getModelConfigValueLabel(property.schema, property.value)); + } + return parts.length ? parts.join(' \u00b7 ') : undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts index f17ea326c9b2f6..621e638015c512 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts @@ -34,6 +34,28 @@ export function getPriceCategoryLabel(priceCategory: string | undefined): string } } +export function isHighCostCategory(priceCategory: string | undefined): boolean { + return priceCategory === 'high' || priceCategory === 'very_high'; +} + +export function getCategoryLabel(category: string | undefined): string | undefined { + switch (category) { + case undefined: + case '': + return undefined; + case 'lightweight': + return localize('chat.category.lightweight', "Lightweight"); + case 'versatile': + return localize('chat.category.versatile', "Versatile"); + case 'powerful': + return localize('chat.category.powerful', "Powerful"); + default: + return typeof category === 'string' + ? category.charAt(0).toUpperCase() + category.slice(1) + : undefined; + } +} + export const enum ModelPickerUnavailableReason { Restricted = 'restricted', SetupRequired = 'setupRequired', diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts new file mode 100644 index 00000000000000..6a9d012a3e899c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts @@ -0,0 +1,453 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { ActionListItemKind, IActionListHeaderLink, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ITabBarAction, ITabDescriptor, TabbedActionListWidget } from '../../../../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; +import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPickerModelConfig.js'; +import { ModelPickerAutoRow } from './modelPickerAutoRow.js'; +import { IPricingDisclosure, ModelCard } from './modelPickerCard.js'; +import { buildSpeedVariants, collapseSpeedVariants, IModelSpeedVariants } from './modelPickerVariants.js'; +import { getModelBadge } from './modelPickerBadges.js'; +import { createModelAction, createUnavailableModelItem, getUnavailableReason } from './modelPickerItemPrimitives.js'; +import { getModelPickerAccessibilityProvider } from './modelPickerItems.js'; +import { isAutoModel } from './modelPickerPresentation.js'; +import { buildModelPickerDestinations, buildModelPickerSections, getModelProviderLabel, hasPromotedModels, IModelPickerDestination, IModelPickerProviderPlaceholder, IModelPickerSections, IModelPickerUnavailableEntry, MODEL_PICKER_BUILT_IN_DESTINATION } from './modelPickerTabs.js'; +import { ModelPickerWelcome } from './modelPickerWelcome.js'; + +/** The collapsible section holding models that are neither pinned, recommended nor recent. */ +const OTHER_MODELS_SECTION = 'other'; +const PICKER_WIDTH = 320; +const PRICING_EXPANDED_STORAGE_KEY = 'chat.modelPicker.pricingExpanded'; + +/** Everything the picker needs for one showing, gathered by the owning widget. */ +export interface ITabbedModelPickerContext { + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + readonly selectedModelId: string | undefined; + readonly recentModelIds: readonly string[]; + readonly pinnedModelIds: readonly string[]; + readonly controlModels: IStringDictionary; + readonly configurationAccess: IModelConfigurationAccess; + /** Whether the account is billed by credits, which is when cost numbers are shown. */ + readonly isUBB: boolean; + readonly showManageModels: boolean; + /** + * What it takes to unlock a curated model the user cannot select yet, used to + * offer the upgrade, admin or update path instead of simply omitting the model. + */ + readonly unavailableContext: { + readonly show: boolean; + readonly currentVSCodeVersion: string; + readonly manageSettingsUrl: string | undefined; + readonly updateStateType: StateType; + }; + /** Reports a click on an upgrade or contact-admin link in an unavailable model row. */ + readonly onUnavailableLinkClick: (uri: URI) => void; + /** Providers the user can add models from but has none from yet, e.g. one that needs signing in. */ + readonly providerPlaceholders: readonly IModelPickerProviderPlaceholder[]; + readonly onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void; + readonly onTogglePin: ((modelIdentifier: string, pinned: boolean) => void) | undefined; + readonly onManageModels: () => void; + /** Reports a configuration change made from a model's detail card. */ + readonly onConfigurationChanged: (model: ILanguageModelChatMetadataAndIdentifier, group: string, key: string, fromValue: unknown, toValue: unknown) => void; + /** Warning banner shown when switching options mid-session would reset the prompt cache. */ + readonly cacheBreakHint: { readonly text: string; readonly link: IActionListHeaderLink | undefined; readonly dismiss: () => void } | undefined; +} + +/** + * A provider-tabbed model picker with a detail card beside the hovered model and an + * Auto row pinned below. With only the built-in provider there is no tab bar. + */ +export class TabbedModelPicker extends Disposable { + + private readonly _onDidHide = this._register(new Emitter()); + readonly onDidHide = this._onDidHide.event; + + private readonly _widget: TabbedActionListWidget; + private readonly _cards = this._register(new DisposableStore()); + private readonly _autoRow = this._register(new MutableDisposable()); + private readonly _onDidChangePricingDisclosure = this._register(new Emitter()); + /** Shared by every card, and remembered, so the breakdown is opened once rather than per model. */ + private readonly _pricingDisclosure: IPricingDisclosure = { + isExpanded: () => this._storageService.getBoolean(PRICING_EXPANDED_STORAGE_KEY, StorageScope.APPLICATION, false), + setExpanded: expanded => { + this._storageService.store(PRICING_EXPANDED_STORAGE_KEY, expanded, StorageScope.APPLICATION, StorageTarget.USER); + this._onDidChangePricingDisclosure.fire(); + }, + onDidChange: this._onDidChangePricingDisclosure.event, + }; + + private _context: ITabbedModelPickerContext | undefined; + private _anchor: HTMLElement | undefined; + private _activeDestination: string | undefined; + private _searchVisible = false; + private _speedVariants: ReadonlyMap = new Map(); + /** The model to fall back to when Auto is switched off. */ + private _lastExplicitModelId: string | undefined; + + get isVisible(): boolean { + return this._widget.isVisible; + } + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IOpenerService private readonly _openerService: IOpenerService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + this._widget = this._register(instantiationService.createInstance(TabbedActionListWidget)); + this._register(this._widget.onDidChangeTab(id => { this._activeDestination = id; })); + this._register(this._widget.onDidHide(() => this._onDidHide.fire())); + } + + hide(): void { + this._widget.hide(); + } + + show(anchor: HTMLElement, context: ITabbedModelPickerContext): void { + this._anchor = anchor; + this._context = context; + if (context.selectedModelId && !this._isAutoSelected(context)) { + this._lastExplicitModelId = context.selectedModelId; + } + this._showCurrent(); + } + + private _showCurrent(): void { + const context = this._context; + const anchor = this._anchor; + if (!context || !anchor) { + return; + } + + this._speedVariants = buildSpeedVariants(context.models); + const listModels = collapseSpeedVariants(context.models, this._speedVariants, context.selectedModelId); + const destinations = buildModelPickerDestinations(listModels, this._languageModelsService, context.providerPlaceholders); + if (!destinations.length) { + return; + } + if (!this._activeDestination || !destinations.some(destination => destination.id === this._activeDestination)) { + this._activeDestination = this._destinationForSelectedModel(destinations, context) ?? destinations[0].id; + } + + const autoModel = context.models.find(isAutoModel); + this._widget.show({ + user: 'ChatTabbedModelPicker', + anchor, + tabs: destinations.map((destination): ITabDescriptor => ({ id: destination.id, label: destination.label, icon: destination.icon, tooltip: destination.label })), + initialTab: this._activeDestination, + tabBarActions: this._buildTabBarActions(context), + tabBarClassName: 'chat-model-picker-tabbar', + // Recomputed on every render so a tab switch reflects the current Auto state. + widgetClassNames: () => [ + 'chat-model-picker-widget', + ...(this._isAutoSelected(this._context ?? context) ? ['auto-enabled'] : []), + ...(this._searchVisible ? ['search-mode'] : []), + ], + tabLabels: 'active', + filterInTabBar: true, + width: PICKER_WIDTH, + createActionList: activeTab => { + this._cards.clear(); + const current = this._context ?? context; + const destination = destinations.find(candidate => candidate.id === activeTab) ?? destinations[0]; + const sections = this._buildSections(destination, current); + // Search spans every destination at once, so each model names its provider. + const items = this._searchVisible + ? destinations.flatMap(candidate => this._buildSearchItems(candidate, current)) + : this._buildItems(destination, sections, current); + return { + items, + listOptions: withChatInputPickerMotion({ + className: 'chat-model-picker-dropdown chat-model-picker-tabbed', + showFilter: this._searchVisible, + filterPlaceholder: localize('chat.modelPicker.search', "Search models"), + focusFilterOnOpen: this._searchVisible, + headerText: current.cacheBreakHint?.text, + headerIcon: current.cacheBreakHint ? Codicon.info : undefined, + headerLink: current.cacheBreakHint?.link, + headerDismiss: current.cacheBreakHint?.dismiss, + // A tab with nothing promoted would open on an empty list, so leave it expanded. + collapsedByDefault: hasPromotedModels(sections) ? new Set([OTHER_MODELS_SECTION]) : undefined, + linkHandler: uri => current.onUnavailableLinkClick(uri), + maxWidth: PICKER_WIDTH, + hideDefaultKeybindingTooltip: true, + // Rows lose their chevron while Auto is on, so hold the gutter open. + reserveSubmenuSpace: 'always', + }), + }; + }, + renderEmpty: (container, activeTab) => { + const destination = destinations.find(candidate => candidate.id === activeTab); + if (!destination?.placeholders.length) { + return undefined; + } + const welcome = new ModelPickerWelcome(destination); + container.appendChild(welcome.element); + return welcome; + }, + renderFooter: autoModel ? container => this._renderAutoRow(container, autoModel, context) : undefined, + delegate: { + onSelect: action => { + void action.run(); + this._widget.hide(); + }, + onHide: () => { }, + }, + accessibilityProvider: getModelPickerAccessibilityProvider(), + }); + } + + private _isAutoSelected(context: ITabbedModelPickerContext): boolean { + const selected = context.models.find(model => model.identifier === context.selectedModelId); + return !!selected && isAutoModel(selected); + } + + private _destinationForSelectedModel(destinations: readonly IModelPickerDestination[], context: ITabbedModelPickerContext): string | undefined { + return destinations.find(destination => destination.models.some(model => model.identifier === context.selectedModelId))?.id; + } + + private _buildSections(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IModelPickerSections { + const isBuiltIn = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION; + return buildModelPickerSections({ + models: destination.models, + selectedModelId: context.selectedModelId, + recentModelIds: context.recentModelIds, + pinnedModelIds: context.pinnedModelIds, + controlModels: context.controlModels, + // Only the built-in provider curates a shortlist. A provider the user added + // gets a tab of its own, which is already the whole of what it offers. + showSuggested: isBuiltIn, + // Only the built-in provider has a curated catalogue to compare against. + showUnavailable: isBuiltIn && context.unavailableContext.show, + currentVSCodeVersion: context.unavailableContext.currentVSCodeVersion, + }); + } + + private _buildTabBarActions(context: ITabbedModelPickerContext): ITabBarAction[] { + const actions: ITabBarAction[] = []; + // Hidden while searching, when the filter takes the tab strip's place. + if (context.showManageModels && !this._searchVisible) { + actions.push({ + id: 'addProvider', + icon: Codicon.add, + tooltip: localize('chat.modelPicker.addProvider', "Add Models..."), + run: () => { + this._widget.hide(); + context.onManageModels(); + }, + }); + } + actions.push({ + id: 'search', + icon: Codicon.search, + tooltip: localize('chat.modelPicker.searchToggle', "Search Models"), + alignEnd: true, + checked: this._searchVisible, + run: () => { + this._searchVisible = !this._searchVisible; + this._showCurrent(); + }, + }); + return actions; + } + + private _buildItems(destination: IModelPickerDestination, sections: IModelPickerSections, context: ITabbedModelPickerContext): IActionListItem[] { + // A plan that grants only Auto still lists the models it could unlock, so the + // welcome body is reserved for having genuinely nothing to say. + if (!destination.models.length && !sections.unavailable.length) { + return []; + } + const items: IActionListItem[] = []; + const appendSection = ( + label: string | undefined, + models: readonly ILanguageModelChatMetadataAndIdentifier[], + unavailable: readonly IModelPickerUnavailableEntry[] = [], + ) => { + if (!models.length && !unavailable.length) { + return; + } + // An unlabelled run still needs a rule when something precedes it. + if (label || items.length) { + items.push({ kind: ActionListItemKind.Separator, label }); + } + for (const model of models) { + items.push(this._createModelItem(model, context, undefined)); + } + // Listed after the models that can be picked, so the section leads with what works. + for (const { id, entry, needsUpdate } of unavailable) { + const { unavailableContext } = context; + const reason = needsUpdate ? 'update' : getUnavailableReason(entry, this._entitlementService, unavailableContext.currentVSCodeVersion); + items.push(createUnavailableModelItem( + id, + entry, + reason, + unavailableContext.manageSettingsUrl, + unavailableContext.updateStateType, + this._entitlementService, + )); + } + }; + + appendSection(localize('chat.modelPicker.pinned', "Pinned"), sections.pinned); + // The shortlist is the default state, so it goes unlabelled. + appendSection(undefined, sections.suggested, sections.unavailable); + + if (sections.other.length) { + const collapsible = hasPromotedModels(sections); + const section = collapsible ? OTHER_MODELS_SECTION : undefined; + if (collapsible) { + const label = localize('chat.modelPicker.otherModels', "Other Models"); + const count = sections.other.length; + items.push({ + item: { id: 'otherModels', enabled: true, checked: false, class: undefined, tooltip: label, label, run: () => { } }, + kind: ActionListItemKind.Action, + label, + badge: String(count), + ariaDescription: localize('chat.modelPicker.otherModelsCount', "{0} more models", count), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: OTHER_MODELS_SECTION, + isSectionToggle: true, + className: 'chat-model-picker-section-toggle', + }); + } + for (const model of sections.other) { + items.push(this._createModelItem(model, context, section)); + } + } + return items; + } + + /** + * Every model in one destination as flat rows, for searching. Sections would only + * get in the way of a result list, but each row still names its provider. + */ + private _buildSearchItems(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IActionListItem[] { + return destination.models + .slice() + .sort((left, right) => left.metadata.name.localeCompare(right.metadata.name)) + .map(model => this._createModelItem(model, context, undefined, getModelProviderLabel(model, this._languageModelsService))); + } + + private _createModelItem( + model: ILanguageModelChatMetadataAndIdentifier, + context: ITabbedModelPickerContext, + section?: string, + providerLabel?: string, + ): IActionListItem { + const { action, ariaDescription } = createModelAction(model, context.selectedModelId, context.onSelect, section, true); + const badge = getModelBadge(model, { configurationAccess: context.configurationAccess, providerLabel }); + // While Auto is choosing, a model's settings do not apply, so the card that edits + // them stays shut. The row is still selectable, which is what turns Auto off. + const autoEnabled = this._isAutoSelected(context); + // Built when the panel first opens: only one card is ever visible, so building + // one per row would render the whole list's worth of markdown and controls. + const createCard = () => this._cards.add(new ModelCard({ + model, + configurationAccess: context.configurationAccess, + isUBB: context.isUBB, + openerService: this._openerService, + isPinned: context.pinnedModelIds.includes(model.identifier), + pricingDisclosure: this._pricingDisclosure, + speedVariants: this._speedVariants.get(model.identifier), + onSelectVariant: next => { + context.onSelect(next); + this._widget.hide(); + }, + onTogglePin: context.onTogglePin + ? pinned => { + context.onTogglePin?.(model.identifier, pinned); + // Closes like every other action in the card, so the card is not left + // open over a list that has since reordered itself. + this._widget.hide(); + } + : undefined, + onDidChangeConfiguration: (group, key, fromValue, toValue) => { + context.onConfigurationChanged(model, group, key, fromValue, toValue); + // Configuring a model is a choice of it: the settings only take effect on the + // model they belong to, so tuning one and leaving another selected would + // discard the change the user just made. + if (model.identifier !== context.selectedModelId) { + context.onSelect(model); + } + this._widget.hide(); + }, + })).element; + return { + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: badge ? undefined : action.description, + badge: badge?.text, + ariaDescription, + group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + section, + className: badge ? `chat-model-picker-badge-${badge.tone}` : undefined, + hover: autoEnabled ? undefined : { content: createCard, expandable: true, panelClassName: 'chat-model-card-panel' }, + tooltip: action.tooltip, + }; + } + + private _renderAutoRow(container: HTMLElement, autoModel: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext): IDisposable { + const row = new ModelPickerAutoRow({ + autoModel, + configurationAccess: context.configurationAccess, + isEnabled: () => this._isAutoSelected(this._context ?? context), + onToggle: enabled => this._toggleAuto(enabled, autoModel), + }); + this._autoRow.value = row; + container.appendChild(row.element); + return row; + } + + private _toggleAuto(enabled: boolean, autoModel: ILanguageModelChatMetadataAndIdentifier): void { + const context = this._context; + if (!context) { + return; + } + const next = enabled ? autoModel : this._fallbackModel(context); + if (!next) { + // Auto is the only model, so there is nothing to switch back to. + this._autoRow.value?.render(); + return; + } + context.onSelect(next); + this._context = { ...context, selectedModelId: next.identifier }; + // Updated in place rather than re-shown: rebuilding the popup would move focus + // off the switch the user just clicked, and can dismiss it outright. + this._widget.refreshActiveList(); + this._autoRow.value?.render(); + } + + /** The model to select when Auto is switched off: the last explicit pick, else the most recent one. */ + private _fallbackModel(context: ITabbedModelPickerContext): ILanguageModelChatMetadataAndIdentifier | undefined { + const candidates = [this._lastExplicitModelId, ...context.recentModelIds, ...context.pinnedModelIds]; + for (const id of candidates) { + const model = context.models.find(candidate => candidate.identifier === id); + if (model && !isAutoModel(model)) { + return model; + } + } + return context.models.find(model => !isAutoModel(model)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts new file mode 100644 index 00000000000000..f333a75ac8667e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { isDefined } from '../../../../../../../base/common/types.js'; +import { localize } from '../../../../../../../nls.js'; +import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; +import { buildModelToProviderGroupMap, getProviderGroupForModel, getProviderGroupKey, isVersionAtLeast } from './modelPickerItemPrimitives.js'; +import { isDeprecated } from './modelPickerBadges.js'; +import { isEarlyAccessModel, latestOfEachLine } from './modelPickerLineage.js'; +import { getProviderIconForIdentity } from './modelProviderIcons.js'; +import { isAutoModel } from './modelPickerPresentation.js'; + +/** The built-in provider's models. */ +export const MODEL_PICKER_BUILT_IN_DESTINATION = 'builtIn'; +/** Prefix for the destination each provider the user added gets. */ +const PROVIDER_DESTINATION_PREFIX = 'provider:'; + +/** A provider the user can add models from but has none from yet, e.g. one that needs signing in. */ +export interface IModelPickerProviderPlaceholder { + readonly vendor: string; + readonly label: string; + /** Why there are no models, shown under the provider name. */ + readonly message: string; + /** Optional call to action, e.g. "Sign in". */ + readonly action?: { readonly label: string; readonly run: () => void }; +} + +/** + * One tab of the picker: the built-in provider, or one the user added. Each names a + * single provider, so a tab's models never need to say where they came from. + */ +export interface IModelPickerDestination { + readonly id: string; + /** Names the destination in the tab tooltip and as the list heading. */ + readonly label: string; + readonly icon: ThemeIcon; + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Providers with nothing to list yet, shown as a welcome body when there are no models. */ + readonly placeholders: readonly IModelPickerProviderPlaceholder[]; +} + +/** + * A model the picker names but the user cannot select yet: their plan does not + * include it, their administrator disabled it, or this build is too old. + */ +export interface IModelPickerUnavailableEntry { + readonly id: string; + readonly entry: IModelControlEntry; + /** The model exists for this account but needs a newer VS Code. */ + readonly needsUpdate: boolean; +} + +/** The rows of one destination, in the order they are shown. */ +export interface IModelPickerSections { + /** The models the user marked, shown under their own heading. */ + readonly pinned: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** The shortlist the picker leads with, shown unlabelled as the body of the list. */ + readonly suggested: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Everything not promoted above, which folds away when there is a shortlist. */ + readonly other: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Curated models the user cannot select yet, shown alongside the recommended ones. */ + readonly unavailable: readonly IModelPickerUnavailableEntry[]; +} + +/** + * Vendor ids that are the built-in provider under another name. Its models reach the + * picker from the extension, from the CLI harness, and as agent-host copies, and each + * of those names a different vendor. + */ +const BUILT_IN_GROUP_IDS: ReadonlySet = new Set([COPILOT_VENDOR_ID, 'copilotcli']); + +/** + * Whether the user brought this model themselves rather than getting it from the + * built-in provider. + * + * This follows the provider group, the same thing the picker names a model's source by, + * rather than the BYOK flags: a host that forwards the built-in provider's models sets + * those flags on every model it relays, which would file the whole catalogue under the + * user's own models. + */ +export function isUserProvidedModel( + model: ILanguageModelChatMetadataAndIdentifier, + languageModelsService: ILanguageModelsService, +): boolean { + const groupId = model.metadata.modelGroup?.id ?? model.metadata.vendor; + if (BUILT_IN_GROUP_IDS.has(groupId)) { + return false; + } + return groupId !== languageModelsService.getVendors().find(vendor => vendor.isDefault)?.vendor; +} + +/** The provider a model came from, as shown in group headings. */ +export function getModelProviderLabel( + model: ILanguageModelChatMetadataAndIdentifier, + languageModelsService: ILanguageModelsService, + modelToGroup = buildModelToProviderGroupMap(languageModelsService), +): string { + return getProviderGroupForModel(model, modelToGroup, languageModelsService).groupName; +} + +/** + * Splits models into one destination per provider: the built-in one first, then each + * provider the user added, by name. Auto is left out because it has its own row, and + * empty providers are dropped so the common case yields no tab bar. + */ +export function buildModelPickerDestinations( + models: readonly ILanguageModelChatMetadataAndIdentifier[], + languageModelsService: ILanguageModelsService, + placeholders: readonly IModelPickerProviderPlaceholder[] = [], +): IModelPickerDestination[] { + const builtInModels: ILanguageModelChatMetadataAndIdentifier[] = []; + const userModels: ILanguageModelChatMetadataAndIdentifier[] = []; + for (const model of models) { + if (isAutoModel(model)) { + continue; + } + (isUserProvidedModel(model, languageModelsService) ? userModels : builtInModels).push(model); + } + + const builtInVendor = languageModelsService.getVendors().find(vendor => vendor.isDefault); + const builtInVendorId = builtInVendor?.vendor ?? COPILOT_VENDOR_ID; + const builtInLabel = builtInVendor?.displayName ?? localize('chat.modelPicker.builtInProvider', "GitHub Copilot"); + const builtInPlaceholders = placeholders.filter(placeholder => placeholder.vendor === builtInVendorId); + const userPlaceholders = placeholders.filter(placeholder => placeholder.vendor !== builtInVendorId); + + // The built-in destination stands even with nothing to list: a plan that only grants + // Auto still needs somewhere to show it, and its curated models still need to name + // the upgrade that would unlock them. + const hasAutoModel = models.some(isAutoModel); + const destinations: IModelPickerDestination[] = []; + if (builtInModels.length || builtInPlaceholders.length || hasAutoModel) { + destinations.push({ + id: MODEL_PICKER_BUILT_IN_DESTINATION, + label: builtInLabel, + icon: getProviderIconForIdentity(`${builtInLabel} ${builtInVendorId}`), + models: builtInModels, + placeholders: builtInPlaceholders, + }); + } + + const modelToGroup = buildModelToProviderGroupMap(languageModelsService); + // Keyed by provider identity rather than by display name, so two providers that + // happen to share a name each keep their own tab instead of being merged. + const byProvider = new Map(); + const providerEntry = (key: string, label: string) => { + let entry = byProvider.get(key); + if (!entry) { + entry = { label, models: [], placeholders: [] }; + byProvider.set(key, entry); + } + return entry; + }; + for (const model of userModels) { + const { vendor, groupName } = getProviderGroupForModel(model, modelToGroup, languageModelsService); + providerEntry(getProviderGroupKey(vendor, groupName), groupName).models.push(model); + } + // A provider still signing in has no models yet, but it has earned its tab. + for (const placeholder of userPlaceholders) { + providerEntry(getProviderGroupKey(placeholder.vendor, placeholder.label), placeholder.label).placeholders.push(placeholder); + } + // Ordered by the name the user reads, with the key breaking ties so that providers + // sharing a name keep a stable order. + const sortedProviders = [...byProvider].sort(([leftKey, left], [rightKey, right]) => + left.label.localeCompare(right.label) || leftKey.localeCompare(rightKey)); + for (const [key, entry] of sortedProviders) { + destinations.push({ + id: `${PROVIDER_DESTINATION_PREFIX}${key}`, + label: entry.label, + icon: getProviderIconForIdentity(entry.label), + models: entry.models, + placeholders: entry.placeholders, + }); + } + return destinations; +} + +export interface IModelPickerSectionsOptions { + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + readonly selectedModelId: string | undefined; + readonly recentModelIds: readonly string[]; + readonly pinnedModelIds: readonly string[]; + readonly controlModels: IStringDictionary; + /** Whether the destination has a curated shortlist to lead with. Only the built-in provider curates one. */ + readonly showSuggested: boolean; + /** Whether to name curated models the user cannot select yet. Off by default. */ + readonly showUnavailable?: boolean; + /** This build's version, used to spot models gated behind a newer VS Code. */ + readonly currentVSCodeVersion?: string; +} + +/** + * Splits a destination's models into favourites, the shortlist to lead with, and the + * rest. Each model appears once, and the selected model is never folded into the rest. + */ +export function buildModelPickerSections(options: IModelPickerSectionsOptions): IModelPickerSections { + // A model this build is too old to run is kept out of every selectable section and + // surfaced only as the update it needs. + const unavailable = buildUnavailableEntries(options); + const gated = new Set(unavailable.filter(entry => entry.needsUpdate).map(entry => entry.id)); + const selectable = gated.size === 0 + ? options.models + : options.models.filter(model => !gated.has(model.metadata.id) && !gated.has(model.identifier)); + + const byIdentifier = new Map(selectable.map(model => [model.identifier, model])); + const byMetadataId = new Map(selectable.map(model => [model.metadata.id, model])); + const placed = new Set(); + const take = (id: string | undefined): ILanguageModelChatMetadataAndIdentifier | undefined => { + const model = id ? byIdentifier.get(id) ?? byMetadataId.get(id) : undefined; + if (!model || placed.has(model.identifier)) { + return undefined; + } + placed.add(model.identifier); + return model; + }; + + const pinned = options.pinnedModelIds.map(take).filter(isDefined); + + const suggested: ILanguageModelChatMetadataAndIdentifier[] = []; + if (options.showSuggested) { + for (const model of options.models) { + if (!model.metadata.promo) { + continue; + } + // Resolved through `take`, which draws only from the selectable models: an + // offer on a model this build is too old to run is surfaced as the update + // it needs rather than as a row that cannot be picked. + const promoted = take(model.identifier); + if (promoted) { + suggested.push(promoted); + } + } + // The newest model of each line leads. A line replaced by a different line rather + // than by a newer version of itself is marked demoted instead. + for (const model of latestOfEachLine(selectable)) { + if (isEarlyAccessModel(model.metadata.id) || options.controlModels[model.metadata.id]?.demoted) { + continue; + } + const latest = take(model.identifier); + if (latest) { + suggested.push(latest); + } + } + // The model in use is never folded away, however the catalogue rates it. + const selected = take(options.selectedModelId); + if (selected) { + suggested.push(selected); + } + } + + const byName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + left.metadata.name.localeCompare(right.metadata.name); + // A time-limited offer leads the shortlist. + const byPromoThenName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + (hasPromo(right) ? 1 : 0) - (hasPromo(left) ? 1 : 0) || byName(left, right); + // A retiring model stays pickable but sinks to the end. + const byRetiringThenName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + (isDeprecated(left) ? 1 : 0) - (isDeprecated(right) ? 1 : 0) || byName(left, right); + const rest = selectable.filter(model => !placed.has(model.identifier)).sort(byRetiringThenName); + + return { + pinned: pinned.sort(byName), + suggested: suggested.sort(byPromoThenName), + other: rest, + unavailable, + }; +} + +/** Whether the model carries an offer worth leading with. */ +function hasPromo(model: ILanguageModelChatMetadataAndIdentifier): boolean { + return ILanguageModelChatMetadata.hasPromoDiscount(model.metadata); +} + +/** + * Curated models with no usable entry here, because the account has no access or this + * build is too old. Named so the path to unlocking them stays visible. + */ +function buildUnavailableEntries(options: IModelPickerSectionsOptions): IModelPickerUnavailableEntry[] { + if (!options.showUnavailable) { + return []; + } + const present = new Set(options.models.flatMap(model => [model.identifier, model.metadata.id])); + const entries: IModelPickerUnavailableEntry[] = []; + for (const [id, entry] of Object.entries(options.controlModels)) { + if (!entry.featured) { + continue; + } + const outOfDate = isOutOfDate(entry, options.currentVSCodeVersion); + // A model that is here but gated needs an update; one that is missing entirely + // needs whatever its account is short of. + if (present.has(id) ? outOfDate : !entry.exists) { + entries.push({ id, entry, needsUpdate: outOfDate }); + } + } + return entries.sort((left, right) => left.entry.label.localeCompare(right.entry.label)); +} + +/** Whether the entry names a minimum VS Code version this build does not meet. */ +function isOutOfDate(entry: IModelControlEntry, currentVSCodeVersion: string | undefined): boolean { + return !!entry.minVSCodeVersion && !!currentVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion); +} + +/** Whether the destination leads with a shortlist that the rest can fold away behind. */ +export function hasPromotedModels(sections: IModelPickerSections): boolean { + return sections.suggested.length > 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts new file mode 100644 index 00000000000000..0d9ec2a16aba5d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { COPILOT_VENDOR_ID, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; + +type ChatThinkingEffortChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed'; + model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the configuration was changed for' }; + property: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The first-party configuration property that was changed (reasoningEffort, or tier for the Auto model); "unknown" for third-party providers, which choose their own keys' }; + fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous value of the configuration property' }; + toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new value of the configuration property' }; +}; + +type ChatThinkingEffortChangeEvent = { + model: string | TelemetryTrustedValue; + property: string; + fromValue: string; + toValue: string; +}; + +type ChatContextSizeChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when the context window size is changed'; + model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the context size was changed for' }; + fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous context size value' }; + toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new context size value' }; +}; + +type ChatContextSizeChangeEvent = { + model: string | TelemetryTrustedValue; + fromValue: string; + toValue: string; +}; + +/** + * Reports a model configuration change. Shared by both model pickers so the same + * change reports identically wherever the user makes it. + */ +export function logModelConfigurationChange( + telemetryService: ITelemetryService, + model: ILanguageModelChatMetadataAndIdentifier, + group: string, + key: string, + fromValue: unknown, + toValue: unknown, +): void { + // Third-party providers choose their own property keys and model ids, so only + // first-party ones are reported as a controlled vocabulary. + const isFirstParty = model.metadata.vendor === COPILOT_VENDOR_ID; + const modelValue = isFirstParty ? new TelemetryTrustedValue(model.identifier) : 'unknown'; + if (group === MODEL_CONFIG_GROUP_CONTEXT) { + telemetryService.publicLog2('chat.contextSizeChange', { + model: modelValue, + fromValue: String(fromValue ?? ''), + toValue: String(toValue), + }); + return; + } + if (group === MODEL_CONFIG_GROUP_EFFORT) { + telemetryService.publicLog2('chat.thinkingEffortChange', { + model: modelValue, + property: isFirstParty ? key : 'unknown', + fromValue: String(fromValue ?? ''), + toValue: String(toValue), + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts new file mode 100644 index 00000000000000..1d44de78400f3b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; + +/** + * The suffix the provider adds to a model id to name its faster twin, e.g. + * `example-2.5` and `example-2.5-fast`. + */ +const FAST_VARIANT_SUFFIX = '-fast'; + +/** A model and the faster twin of it that the provider also offers. */ +export interface IModelSpeedVariants { + readonly standard: ILanguageModelChatMetadataAndIdentifier; + readonly fast: ILanguageModelChatMetadataAndIdentifier; +} + +/** + * Pairs each model with its faster twin, keyed by both of their identifiers. Reads + * model ids rather than display names, since only the id is the provider's own + * identifier. A stopgap until models describe the relationship themselves. + */ +export function buildSpeedVariants( + models: readonly ILanguageModelChatMetadataAndIdentifier[], +): ReadonlyMap { + // Keyed by vendor as well, so two providers offering the same model id are not paired. + const key = (model: ILanguageModelChatMetadataAndIdentifier, id: string) => `${model.metadata.vendor}/${id}`; + const byModelId = new Map(); + for (const model of models) { + byModelId.set(key(model, model.metadata.id), model); + } + + const pairs = new Map(); + for (const model of models) { + const id = model.metadata.id; + if (!id.endsWith(FAST_VARIANT_SUFFIX)) { + continue; + } + const standard = byModelId.get(key(model, id.slice(0, -FAST_VARIANT_SUFFIX.length))); + if (!standard) { + continue; + } + const pair: IModelSpeedVariants = { standard, fast: model }; + pairs.set(standard.identifier, pair); + pairs.set(model.identifier, pair); + } + return pairs; +} + +/** + * Drops the twin that is not in use, so a pair takes one row rather than two. The + * selected twin is the one kept, since a picker that hides the current choice cannot + * be read as showing it. + */ +export function collapseSpeedVariants( + models: readonly ILanguageModelChatMetadataAndIdentifier[], + variants: ReadonlyMap, + selectedModelId: string | undefined, +): ILanguageModelChatMetadataAndIdentifier[] { + if (!variants.size) { + return [...models]; + } + return models.filter(model => { + const pair = variants.get(model.identifier); + if (!pair) { + return true; + } + const inUse = selectedModelId === pair.fast.identifier ? pair.fast : pair.standard; + return model.identifier === inUse.identifier; + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts new file mode 100644 index 00000000000000..c362d1dd792574 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Button } from '../../../../../../../base/browser/ui/button/button.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { getProviderIconForIdentity } from './modelProviderIcons.js'; +import { IModelPickerDestination } from './modelPickerTabs.js'; + +/** + * The body shown when a destination has no models: each waiting provider's icon + * and name, why it is empty, and the action that fills it, e.g. signing in. + */ +export class ModelPickerWelcome extends DisposableStore { + + readonly element = dom.$('.chat-model-picker-welcome'); + + constructor(destination: IModelPickerDestination) { + super(); + for (const placeholder of destination.placeholders) { + const icon = destination.placeholders.length === 1 + ? destination.icon + : getProviderIconForIdentity(`${placeholder.label} ${placeholder.vendor}`); + const entry = dom.append(this.element, dom.$('.chat-model-picker-welcome-provider')); + dom.append(entry, dom.$(`span.chat-model-picker-welcome-icon${ThemeIcon.asCSSSelector(icon)}`)); + dom.append(entry, dom.$('.chat-model-picker-welcome-title', undefined, placeholder.label)); + dom.append(entry, dom.$('.chat-model-picker-welcome-message', undefined, placeholder.message)); + if (placeholder.action) { + const button = this.add(new Button(entry, { ...defaultButtonStyles, title: placeholder.action.label })); + button.label = placeholder.action.label; + this.add(button.onDidClick(() => placeholder.action?.run())); + } + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index b44caef1f00c0d..54a0d3c0b4c17b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -30,7 +30,7 @@ import { IProductService } from '../../../../../../../platform/product/common/pr import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; -import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { COPILOT_VENDOR_ID, getLanguageModelProviderDisplayName, IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; @@ -45,9 +45,17 @@ import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; import { buildModelPickerItems, createManageModelsAction, getModelPickerAccessibilityProvider, getModelPickerControlModels, ModelPickerSection, shouldShowManageModelsAction } from './modelPickerItems.js'; import { ModelPickerConfiguration } from './modelPickerConfiguration.js'; import { getCompactModelPickerIcon } from './modelProviderIcons.js'; +import { ITabbedModelPickerContext, TabbedModelPicker } from './modelPickerTabbedWidget.js'; +import { getModelConfigSummary } from './modelPickerModelConfig.js'; +import { logModelConfigurationChange } from './modelPickerTelemetry.js'; +import { IModelPickerProviderPlaceholder } from './modelPickerTabs.js'; import { getModelPickerUnavailableReason, isAutoModel, ModelPickerUnavailableReason, modelPickerRequiresSetup, shouldShowCacheBreakHint as computeShouldShowCacheBreakHint } from './modelPickerPresentation.js'; const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; + +/** Opt-in setting for the tabbed model picker, which replaces the flat dropdown and the separate configuration button. */ +export const TABBED_MODEL_PICKER_SETTING_ID = 'chat.experimentalModelPicker'; + const MODEL_PICKER_MINIMUM_LABEL_WIDTH = 60; const MODEL_PICKER_NAME_CHROME_WIDTH = 30; const MODEL_PICKER_MINIMUM_NAME_WIDTH = MODEL_PICKER_MINIMUM_LABEL_WIDTH + MODEL_PICKER_NAME_CHROME_WIDTH; @@ -124,6 +132,8 @@ export class ModelPickerWidget extends Disposable { private _configButton: HTMLElement | undefined; private _minimumWidth = MODEL_PICKER_MINIMUM_NAME_WIDTH; private readonly _configuration: ModelPickerConfiguration; + private readonly _tabbedPicker = this._register(new MutableDisposable()); + private readonly _tabbedPickerHideListener = this._register(new MutableDisposable()); get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { return this._selectedModel; @@ -165,10 +175,10 @@ export class ModelPickerWidget extends Disposable { @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @IStorageService private readonly _storageService: IStorageService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._configuration = instantiationService.createInstance(ModelPickerConfiguration, { + this._configuration = this._instantiationService.createInstance(ModelPickerConfiguration, { getSelectedModel: () => this._selectedModel, getConfigurationAccess: () => this._delegate.modelConfiguration ?? this._languageModelsService, isDisabled: () => !!this._domNode?.classList.contains('disabled'), @@ -228,6 +238,12 @@ export class ModelPickerWidget extends Disposable { this._renderLabel(); })); } + + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(TABBED_MODEL_PICKER_SETTING_ID)) { + this._renderLabel(); + } + })); } setCompact(compact: IObservable): void { @@ -437,13 +453,53 @@ export class ModelPickerWidget extends Disposable { }); } + /** Whether the user opted into the tabbed picker, which folds model configuration into the list. */ + isTabbedPickerEnabled(): boolean { + return this._configurationService.getValue(TABBED_MODEL_PICKER_SETTING_ID) === true; + } + + /** + * Providers that need a welcome body instead of a list. Only the built-in provider + * qualifies today, when it still needs sign-in. Providers the user has not set up are + * reached through "Add Models" rather than given a tab. + */ + private _providerPlaceholders(): IModelPickerProviderPlaceholder[] { + if (!this.isSetupRequired()) { + return []; + } + return [{ + vendor: COPILOT_VENDOR_ID, + label: getLanguageModelProviderDisplayName(this._languageModelsService, COPILOT_VENDOR_ID), + message: localize('chat.modelPicker.signInMessage', "Sign in to see available models."), + action: { label: localize('chat.modelPicker.signIn', "Sign in"), run: () => this._requestSetup() }, + }]; + } + + private _showTabbedPicker(anchor: HTMLElement, context: ITabbedModelPickerContext): void { + const picker = this._tabbedPicker.value ?? (this._tabbedPicker.value = this._instantiationService.createInstance(TabbedModelPicker)); + const previouslyFocusedElement = dom.getActiveElement(); + this._tabbedPickerHideListener.value = picker.onDidHide(() => { + this._tabbedPickerHideListener.clear(); + this._nameButton?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + }); + this._nameButton?.setAttribute('aria-expanded', 'true'); + picker.show(anchor, context); + } + show(anchor?: HTMLElement): void { const anchorElement = anchor ?? this._domNode; if (!anchorElement || this._domNode?.classList.contains('disabled')) { return; } if (this._nameButton?.getAttribute('aria-expanded') === 'true') { - this._actionWidgetService.hide(true); + if (this._tabbedPicker.value?.isVisible) { + this._tabbedPicker.value.hide(); + } else { + this._actionWidgetService.hide(true); + } return; } @@ -490,6 +546,48 @@ export class ModelPickerWidget extends Disposable { this.show(anchorElement); }; + const onLinkClick = (uri: URI) => { + if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { + logModelPickerInteraction('premiumModelUpgradePlanClicked'); + } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { + logModelPickerInteraction('disabledModelContactAdminClicked'); + } + void this._openerService.open(uri, { allowCommands: true }); + }; + + const placeholders = this._providerPlaceholders(); + if (this.isTabbedPickerEnabled() && !this.isRestrictedMode() && (models.length > 0 || placeholders.length > 0)) { + const showCacheBreakHint = this.shouldShowCacheBreakHint(/* excludeAutoModel */ true); + this._showTabbedPicker(anchorElement, { + models, + selectedModelId: this._selectedModel?.identifier, + recentModelIds: this._languageModelsService.getRecentlyUsedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + pinnedModelIds: this._languageModelsService.getPinnedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + controlModels: controlModelsForTier, + configurationAccess: this._delegate.modelConfiguration ?? this._languageModelsService, + isUBB: !!this._entitlementService.quotas.usageBasedBilling, + showManageModels: !!manageModelsAction, + providerPlaceholders: placeholders, + unavailableContext: { + show: presentation.showUnavailableFeatured, + currentVSCodeVersion: this._productService.version, + manageSettingsUrl, + updateStateType: this._updateService.state.type, + }, + onUnavailableLinkClick: onLinkClick, + onSelect, + onTogglePin, + onManageModels: () => manageModelsAction?.run(), + onConfigurationChanged: (model, group, key, fromValue, toValue) => logModelConfigurationChange(this._telemetryService, model, group, key, fromValue, toValue), + cacheBreakHint: showCacheBreakHint ? { + text: localize('chat.modelPicker.cacheBreakHint', "Switching models mid-session resets the prompt cache and may increase cost."), + link: this.getCacheBreakLearnMoreLink(), + dismiss: () => this.dismissCacheBreakHint(), + } : undefined, + }); + return; + } + const items = buildModelPickerItems({ models, selectedModelId: this._selectedModel?.identifier, @@ -551,14 +649,7 @@ export class ModelPickerWidget extends Disposable { logModelPickerInteraction(collapsed ? 'otherModelsCollapsed' : 'otherModelsExpanded'); } }, - linkHandler: (uri: URI) => { - if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { - logModelPickerInteraction('premiumModelUpgradePlanClicked'); - } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { - logModelPickerInteraction('disabledModelContactAdminClicked'); - } - void this._openerService.open(uri, { allowCommands: true }); - }, + linkHandler: onLinkClick, minWidth: 200, }); const previouslyFocusedElement = dom.getActiveElement(); @@ -643,7 +734,15 @@ export class ModelPickerWidget extends Disposable { : genericNoModels ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); + // The tabbed picker has no separate configuration button, so the chip reads out + // what the model was tuned to. + const configSummary = this.isTabbedPickerEnabled() && !unavailable && !minimal + ? getModelConfigSummary(this._selectedModel, this._delegate.modelConfiguration ?? this._languageModelsService) + : undefined; const showModelLabel = !compact || !modelIcon || noModelsAvailable; + // Fixed rather than measured: this runs from a resize-driven autorun, so reading + // the rendered width here would dirty layout from inside the ResizeObserver + // callback and never settle. const showingAuto = !unavailable && !activating && !genericNoModels && (!this._selectedModel || isAutoModel(this._selectedModel)); const nameMinimumWidth = compact && !showModelLabel ? MODEL_PICKER_COMPACT_NAME_WIDTH @@ -654,13 +753,20 @@ export class ModelPickerWidget extends Disposable { if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } + if (configSummary && showModelLabel) { + nameChildren.push(dom.$('span.model-picker-config-summary', undefined, configSummary)); + } if (this._badgeIcon) { nameChildren.push(this._badgeIcon); } dom.reset(this._nameButton, ...nameChildren); if (this._configButton) { - this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); + if (this.isTabbedPickerEnabled()) { + this._configButton.style.display = 'none'; + } else { + this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); + } } const configVisible = !!this._configButton && this._configButton.style.display !== 'none'; this._domNode.classList.toggle('icon-only', !showModelLabel && !configVisible); @@ -671,7 +777,9 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.ariaLabelRestricted', "Models, unavailable while in Restricted mode") : setupRequired ? localize('chat.modelPicker.ariaLabelSetupRequired', "Models, sign in to use Copilot") - : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); + : configSummary + ? localize('chat.modelPicker.ariaLabelConfigured', "Models, {0}, {1}", modelLabel, configSummary) + : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); this._domNode.ariaLabel = ariaLabel; this._nameButton.ariaLabel = ariaLabel; this._updateMinimumWidth(nameMinimumWidth); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts index b252cbfd854db6..481a2615a87c9c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts @@ -20,39 +20,53 @@ const xAIModelProviderIcon = registerIcon('chat-model-provider-xai', Codicon.xai const genericModelProviderIcon = registerIcon('chat-model-provider-generic', Codicon.sparkle, localize('chatModelProviderGenericIcon', "Icon for other model providers.")); const genericModelProviderCompactIcon = registerIcon('chat-model-provider-generic-compact', Codicon.sparkleCompact, localize('chatModelProviderGenericCompactIcon', "Compact icon for other model providers.")); -export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { - const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`.toLowerCase(); - if (identity.includes('grok') || identity.includes('xai')) { +/** + * The provider icon matching a free-form identity string (vendor, family, model + * or provider name). Falls back to a generic icon when nothing matches. + */ +export function getProviderIconForIdentity(identity: string, copilotIdentity: string = identity): ThemeIcon { + const normalized = identity.toLowerCase(); + if (normalized.includes('grok') || normalized.includes('xai')) { return xAIModelProviderIcon; } - if (model.metadata.isBYOK) { - return genericModelProviderIcon; - } - if (isAutoLanguageModel(model)) { - return copilotModelProviderIcon; - } - if (identity.includes('claude') || identity.includes('anthropic')) { + if (normalized.includes('claude') || normalized.includes('anthropic')) { return claudeModelProviderIcon; } - if (identity.includes('gemini') || identity.includes('google')) { + if (normalized.includes('gemini') || normalized.includes('google')) { return geminiModelProviderIcon; } - if (identity.includes('kimi') || identity.includes('moonshot')) { + if (normalized.includes('kimi') || normalized.includes('moonshot')) { return kimiModelProviderIcon; } - if (identity.includes('microsoft') || /\bmai\b/.test(identity)) { + if (normalized.includes('microsoft') || /\bmai\b/.test(normalized)) { return microsoftModelProviderIcon; } - if (identity.includes('openai') || identity.includes('gpt') || identity.includes('codex') || /\bo[134]\b/.test(identity)) { + if (normalized.includes('openai') || normalized.includes('chatgpt') || normalized.includes('gpt') || normalized.includes('codex') || /\bo[134]\b/.test(normalized)) { return openAIModelProviderIcon; } - const modelIdentity = `${model.metadata.id} ${model.metadata.name}`.toLowerCase(); - if (modelIdentity.includes('copilot')) { + // Checked last, so a more specific match always wins. + if (copilotIdentity.toLowerCase().includes('copilot')) { return copilotModelProviderIcon; } return genericModelProviderIcon; } +export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { + const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`; + if (/grok|xai/i.test(identity)) { + return xAIModelProviderIcon; + } + if (model.metadata.isBYOK) { + return genericModelProviderIcon; + } + if (isAutoLanguageModel(model)) { + return copilotModelProviderIcon; + } + // The Copilot fallback reads the model's own name only: the vendor is `copilot` for + // every first-party model, so including it would brand the whole catalogue. + return getProviderIconForIdentity(identity, `${model.metadata.id} ${model.metadata.name}`); +} + export function getModelPickerIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { return model.metadata.statusIcon ?? getModelProviderIcon(model); } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 47beba3ef9419e..b675ac35e43742 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -760,6 +760,12 @@ export function getLanguageModelDisplayNameWithProvider(model: ILanguageModelCha export interface IModelControlEntry { readonly label: string; readonly featured?: boolean; + /** + * Keeps the model out of the shortlist the picker leads with, even when it is the + * newest of its line. For a line that has been replaced by another rather than by a + * newer version of itself, which no rule can work out on its own. + */ + readonly demoted?: boolean; readonly minVSCodeVersion?: string; readonly exists: boolean; } @@ -2495,7 +2501,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelCache.has(`copilot/${entry.id}`) }; + free[entry.id] = { label: entry.label, featured: entry.featured, demoted: entry.demoted, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -2505,7 +2511,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; + paid[entry.id] = { label: entry.label, featured: entry.featured, demoted: entry.demoted, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts new file mode 100644 index 00000000000000..ba4d94751e6be0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IStringDictionary } from '../../../../../../../../base/common/collections.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { ModelPickerAutoRow } from '../../../../../browser/widget/input/modelPicker/modelPickerAutoRow.js'; +import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../../common/languageModels.js'; + +function createAutoModel(): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: 'copilot/auto', + metadata: { + id: 'auto', + name: 'Auto', + vendor: 'copilot', + version: '1.0', + family: 'auto', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + } as ILanguageModelChatMetadata, + }; +} + +function createConfigurationAccess(): IModelConfigurationAccess { + const values: IStringDictionary> = {}; + return { + getModelConfiguration: modelId => values[modelId], + setModelConfiguration: async (modelId, next) => { values[modelId] = { ...values[modelId], ...next }; }, + getModelConfigurationActions: () => [], + }; +} + +suite('ModelPickerAutoRow', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createRow(initiallyEnabled: boolean) { + const toggles: boolean[] = []; + let enabled = initiallyEnabled; + const row = disposables.add(new ModelPickerAutoRow({ + autoModel: createAutoModel(), + configurationAccess: createConfigurationAccess(), + isEnabled: () => enabled, + onToggle: next => { + enabled = next; + toggles.push(next); + }, + })); + const element = row.element; + return { + toggles, + main: element.querySelector('.chat-model-picker-auto-main') as HTMLElement, + label: element.querySelector('.chat-model-picker-auto-label') as HTMLElement, + description: element.querySelector('.chat-model-picker-auto-description') as HTMLElement, + toggle: element.querySelector('.monaco-switch') as HTMLElement, + }; + } + + test('clicking the label toggles Auto on', () => { + const { toggles, label, toggle } = createRow(false); + + label.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + test('clicking the label toggles Auto back off', () => { + const { toggles, label, toggle } = createRow(true); + + label.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [false], checked: 'false' }); + }); + + test('clicking the switch itself reports one change, not two', () => { + const { toggles, toggle } = createRow(false); + + toggle.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + test('clicking the strip beside the label toggles Auto', () => { + const { toggles, main, toggle } = createRow(false); + + main.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + // The row sits in the popup's footer, which is dismissed when focus leaves it. + // Pressing inert parts of the row must not move focus, or the popup closes first. + test('pressing the strip and the description does not move focus', () => { + const { main, description } = createRow(false); + const defaultPrevented = (target: HTMLElement) => { + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + + assert.deepStrictEqual( + { strip: defaultPrevented(main), description: defaultPrevented(description) }, + { strip: true, description: true }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts index 2c224b08edd5ff..22d43b834651cd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts @@ -11,7 +11,7 @@ import { IActionWidgetService } from '../../../../../../../../platform/actionWid import { IActionWidgetDropdownAction } from '../../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ITelemetryService } from '../../../../../../../../platform/telemetry/common/telemetry.js'; import { ModelPickerConfiguration } from '../../../../../browser/widget/input/modelPicker/modelPickerConfiguration.js'; -import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerActionItem.js'; +import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../../common/languageModels.js'; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts new file mode 100644 index 00000000000000..1e2b343b962ff0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts @@ -0,0 +1,660 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IStringDictionary } from '../../../../../../../../base/common/collections.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { IModelConfigurationAccess, getModelConfigProperty, getModelConfigSummary, isExtendedContext, MODEL_CONFIG_GROUP_CONTEXT } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { getModelBadge } from '../../../../../browser/widget/input/modelPicker/modelPickerBadges.js'; +import { latestOfEachLine, parseModelLine } from '../../../../../browser/widget/input/modelPicker/modelPickerLineage.js'; +import { buildSpeedVariants, collapseSpeedVariants } from '../../../../../browser/widget/input/modelPicker/modelPickerVariants.js'; +import { buildModelPickerDestinations, buildModelPickerSections, hasPromotedModels, IModelPickerProviderPlaceholder } from '../../../../../browser/widget/input/modelPicker/modelPickerTabs.js'; +import { getProviderGroupKey } from '../../../../../browser/widget/input/modelPicker/modelPickerItemPrimitives.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../../common/languageModels.js'; + +interface IFixtureModelOptions { + readonly vendor?: string; + readonly isBYOK?: boolean; + readonly byokModelIdentifier?: string; + readonly modelGroupId?: string; +} + +function createModel(id: string, name: string, options: IFixtureModelOptions = {}): ILanguageModelChatMetadataAndIdentifier { + const vendor = options.vendor ?? 'copilot'; + return { + identifier: `${vendor}/${id}`, + metadata: { + id, + name, + vendor, + version: '1.0', + family: id, + isBYOK: options.isBYOK, + byokModelIdentifier: options.byokModelIdentifier, + modelGroup: options.modelGroupId ? { id: options.modelGroupId } : undefined, + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + } as ILanguageModelChatMetadata, + }; +} + +function createConfigurableModel(): ILanguageModelChatMetadataAndIdentifier { + const model = createModel('gpt-5-5', 'GPT-5.5'); + return { + ...model, + metadata: { + ...model.metadata, + configurationSchema: { + properties: { + reasoningEffort: { + type: 'string', + group: 'navigation', + enum: ['low', 'medium', 'xhigh'], + enumItemLabels: ['Low', 'Medium', 'Extra high'], + default: 'medium', + }, + contextSize: { + type: 'number', + group: 'tokens', + enum: [264000, 1000000], + enumItemLabels: ['264K', '1M'], + default: 264000, + }, + }, + }, + }, + }; +} + +function createLanguageModelsService(): ILanguageModelsService { + return { + getVendors: () => [ + { vendor: 'copilot', displayName: 'GitHub Copilot', isDefault: true }, + { vendor: 'ollama', displayName: 'Ollama', isDefault: false }, + { vendor: 'openai', displayName: 'OpenAI', isDefault: false }, + ], + getLanguageModelGroups: () => [], + } as unknown as ILanguageModelsService; +} + +function createConfigurationAccess(values: IStringDictionary = {}): IModelConfigurationAccess { + return { + getModelConfiguration: () => values, + setModelConfiguration: async () => { }, + getModelConfigurationActions: () => [], + }; +} + +suite('Model picker destinations', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const service = createLanguageModelsService(); + const auto = createModel('auto', 'Auto'); + const gpt = createModel('gpt-5-5', 'GPT-5.5'); + const claude = createModel('claude-sonnet-5', 'Claude Sonnet 5'); + const gemini = createModel('gemini-3-1-pro', 'Gemini 3.1 Pro'); + const llama = createModel('llama-3', 'Llama 3', { vendor: 'ollama', isBYOK: true }); + const mistral = createModel('mistral', 'Mistral Large', { vendor: 'openai', isBYOK: true }); + + const summarize = (models: readonly ILanguageModelChatMetadataAndIdentifier[], placeholders: readonly IModelPickerProviderPlaceholder[] = []) => + buildModelPickerDestinations(models, service, placeholders) + .map(destination => ({ id: destination.id, label: destination.label, models: destination.models.map(model => model.metadata.name) })); + + /** The destination id a provider gets, keyed by identity rather than by display name. */ + const providerId = (vendor: string, label: string) => `provider:${getProviderGroupKey(vendor, label)}`; + + test('a host that relays the built-in provider keeps its models built in', () => { + // An agent host registers a vendor of its own and republishes the built-in + // provider's catalogue under it, so neither the vendor nor the BYOK flags say + // where a model actually came from. Only the provider group does. + const relayed = createModel('gpt-5-5', 'GPT-5.5', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + byokModelIdentifier: 'copilot/gpt-5-5', + modelGroupId: 'copilot', + }); + const relayedNative = createModel('cli-model', 'CLI Model', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + modelGroupId: 'copilotcli', + }); + const relayedByok = createModel('llama-3', 'Llama 3', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + modelGroupId: 'ollama', + }); + assert.deepStrictEqual( + summarize([relayed, relayedNative, relayedByok]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5', 'CLI Model'] }, + // The relayed model carries the host's vendor, so that is the identity it + // is keyed by; only the group supplies the name the user reads. + { id: providerId('agent-host-copilotcli', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + ], + ); + }); + + test('a plan that grants only Auto still opens on the built-in destination', () => { + // Auto has its own row rather than a place in the list, so without this the + // picker would have no destination at all and open on nothing. + assert.deepStrictEqual( + summarize([auto]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: [] }], + ); + }); + + test('models from only the built-in provider yield a single destination', () => { + assert.deepStrictEqual( + summarize([auto, gpt, claude]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5', 'Claude Sonnet 5'] }], + ); + }); + + test('a provider the user added gets its own destination', () => { + assert.deepStrictEqual( + summarize([gpt, llama]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + ], + ); + }); + + test('every provider the user added gets a destination, in name order', () => { + assert.deepStrictEqual( + summarize([gpt, mistral, llama]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + { id: providerId('openai', 'OpenAI'), label: 'OpenAI', models: ['Mistral Large'] }, + ], + ); + }); + + test('two providers sharing a display name each keep their own tab', () => { + // Nothing stops two providers from presenting the same name, and merging them + // would file one provider's models under the other. + const sameName = createModel('local-1', 'Local One', { vendor: 'ollama', isBYOK: true }); + const alsoSameName = createModel('local-2', 'Local Two', { vendor: 'openai', isBYOK: true }); + const service = { + getVendors: () => [ + { vendor: 'copilot', displayName: 'GitHub Copilot', isDefault: true }, + { vendor: 'ollama', displayName: 'Local Models', isDefault: false }, + { vendor: 'openai', displayName: 'Local Models', isDefault: false }, + ], + getLanguageModelGroups: () => [], + } as unknown as ILanguageModelsService; + + assert.deepStrictEqual( + buildModelPickerDestinations([sameName, alsoSameName], service) + .map(destination => ({ id: destination.id, label: destination.label, models: destination.models.map(model => model.metadata.name) })), + [ + { id: providerId('ollama', 'Local Models'), label: 'Local Models', models: ['Local One'] }, + { id: providerId('openai', 'Local Models'), label: 'Local Models', models: ['Local Two'] }, + ], + ); + }); + + test('a provider waiting on sign-in gets its own destination with no models', () => { + assert.deepStrictEqual( + summarize([gpt], [{ vendor: 'ollama', label: 'Ollama', message: 'Sign in to see available models.' }]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: [] }, + ], + ); + }); + + test('the built-in provider waiting on sign-in still gets its destination', () => { + assert.deepStrictEqual( + summarize([], [{ vendor: 'copilot', label: 'GitHub Copilot', message: 'Sign in to see available models.' }]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: [] }], + ); + }); + + test('sections place each model once, with the selected model in the shortlist', () => { + const controlModels: IStringDictionary = { + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', featured: true, exists: true }, + }; + const sections = buildModelPickerSections({ + models: [gpt, claude, gemini], + selectedModelId: 'copilot/gpt-5-5', + recentModelIds: ['copilot/gemini-3-1-pro'], + pinnedModelIds: ['copilot/claude-sonnet-5'], + controlModels, + showSuggested: true, + }); + assert.deepStrictEqual( + { + pinned: sections.pinned.map(model => model.metadata.name), + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other, + }, + { + pinned: ['Claude Sonnet 5'], + // The selected model rides along with the curated ones, so it is never folded away. + suggested: ['Gemini 3.1 Pro', 'GPT-5.5'], + other: [], + }, + ); + }); + + test('a model superseded within its line falls through to the rest', () => { + const sections = buildModelPickerSections({ + models: [createModel('example-5.5', 'Example 5.5'), createModel('example-5.6', 'Example 5.6'), claude], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: {}, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + promoted: hasPromotedModels(sections), + }, + { suggested: ['Claude Sonnet 5', 'Example 5.6'], other: ['Example 5.5'], promoted: true }, + ); + }); + + test('a retiring model sinks below the models that are staying', () => { + // Alphabetically "Alpha" leads, so only the retirement can move it last. + const base = createModel('alpha', 'Alpha'); + const alpha = { ...base, metadata: { ...base.metadata, warningText: { model_pending_deprecation: 'Retiring soon.' } } }; + const sections = buildModelPickerSections({ + models: [alpha, claude, gemini], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'alpha': { label: 'Alpha', exists: true, demoted: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true }, + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', exists: true, demoted: true }, + }, + showSuggested: true, + }); + assert.deepStrictEqual( + sections.other.map(model => model.metadata.name), + ['Claude Sonnet 5', 'Gemini 3.1 Pro', 'Alpha'], + ); + }); + + test('the built-in destination is the only one that recommends models', () => { + const sections = buildModelPickerSections({ + models: [llama], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'llama-3': { label: 'Llama 3', featured: true, exists: true } }, + showSuggested: false, + }); + assert.deepStrictEqual( + { suggested: sections.suggested.length, other: sections.other.map(model => model.metadata.name) }, + { suggested: 0, other: ['Llama 3'] }, + ); + }); + + test('a destination with no shortlist puts every model in the list', () => { + const sections = buildModelPickerSections({ + models: [llama, mistral], + selectedModelId: 'ollama/llama-3', + recentModelIds: ['openai/mistral'], + pinnedModelIds: [], + controlModels: {}, + showSuggested: false, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.length, + other: sections.other.map(model => model.metadata.name), + }, + { suggested: 0, other: ['Llama 3', 'Mistral Large'] }, + ); + }); + + test('a model id splits into the line it belongs to and its version', () => { + // The id shapes a provider can use: version last, version in the middle, a + // multi-word line, and no version at all. + assert.deepStrictEqual( + ['example-5.5', 'example-5.6-sol', 'example-1.1-lite', 'example-opus-5', 'example-prime'] + .map(id => ({ id, ...parseModelLine(id) })), + [ + { id: 'example-5.5', line: 'example', version: [5, 5] }, + { id: 'example-5.6-sol', line: 'example-sol', version: [5, 6] }, + { id: 'example-1.1-lite', line: 'example-lite', version: [1, 1] }, + { id: 'example-opus-5', line: 'example-opus', version: [5] }, + // No version token, so it stands as its own line rather than being buried. + { id: 'example-prime', line: 'example-prime', version: [] }, + ], + ); + }); + + test('the shortlist is the newest of each line, so a launch needs no list edit', () => { + const line = (id: string, vendor = 'copilot') => createModel(id, id, { vendor }); + const shortlist = (models: readonly ILanguageModelChatMetadataAndIdentifier[]) => + latestOfEachLine(models).map(model => model.metadata.id).sort(); + + const catalogue = [line('example-5.6-sol'), line('example-opus-4.8'), line('example-opus-5')]; + assert.deepStrictEqual( + { + today: shortlist(catalogue), + // A newer version of a line replaces it; a line of its own joins the shortlist. + afterLaunch: shortlist([...catalogue, line('example-5.7-sol'), line('example-6-vega')]), + // `example-5.5` is the `example` line and `example-5.6-sol` is the + // `example-sol` line, so neither supersedes the other. Replacing a line + // takes a name, not a rule. + acrossLines: shortlist([line('example-5.5'), line('example-5.6-sol')]), + // Two providers can ship the same line name without displacing each other. + perVendor: shortlist([line('example-5.4-mini'), line('example-5-mini', 'azure')]), + }, + { + today: ['example-5.6-sol', 'example-opus-5'], + afterLaunch: ['example-5.7-sol', 'example-6-vega', 'example-opus-5'], + acrossLines: ['example-5.5', 'example-5.6-sol'], + perVendor: ['example-5-mini', 'example-5.4-mini'], + }, + ); + }); + + test('a line replaced by a different line is demoted by name, and promos still lead', () => { + const sol = createModel('example-5.6-sol', 'Example 5.6 Sol'); + const codex = createModel('example-5.3-codex', 'Example 5.3 Codex'); + const promoCodex = { ...codex, metadata: { ...codex.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const sections = (codexModel: ILanguageModelChatMetadataAndIdentifier) => buildModelPickerSections({ + models: [sol, codexModel], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + // Codex is the newest of its own line, so only a name can move it down. + controlModels: { 'example-5.3-codex': { label: 'Example 5.3 Codex', exists: true, demoted: true } }, + showSuggested: true, + }); + assert.deepStrictEqual( + { + demoted: { + suggested: sections(codex).suggested.map(model => model.metadata.id), + other: sections(codex).other.map(model => model.metadata.id), + }, + // An offer outranks the demotion: it is time-limited and worth seeing. + withPromo: sections(promoCodex).suggested.map(model => model.metadata.id), + }, + { + demoted: { suggested: ['example-5.6-sol'], other: ['example-5.3-codex'] }, + withPromo: ['example-5.3-codex', 'example-5.6-sol'], + }, + ); + }); + + test('demoting every model leaves the list open rather than empty', () => { + // A demotion is honoured whether or not anything replaced the model, so a config + // that names them all is possible. The fold has nothing to hide behind then, and + // the rest is shown instead of the picker opening on nothing. + const sections = buildModelPickerSections({ + models: [gpt, claude], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'gpt-5-5': { label: 'GPT-5.5', exists: true, demoted: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true }, + }, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.length, + other: sections.other.map(model => model.metadata.name), + // False, so the caller leaves the rest expanded instead of folding it away. + folds: hasPromotedModels(sections), + }, + { suggested: 0, other: ['Claude Sonnet 5', 'GPT-5.5'], folds: false }, + ); + }); + + test('an early-access build stays out of the shortlist without being named', () => { + const sections = buildModelPickerSections({ + models: [gpt, createModel('example-3-eap', 'Example 3 EAP'), createModel('example-4-experimental', 'Example 4')], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + // No entry for either: the id says enough, so a new one needs no config. + controlModels: {}, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.id), + // Held back from the shortlist, not hidden: still selectable further down. + other: sections.other.map(model => model.metadata.id), + }, + { + suggested: ['gpt-5-5'], + other: ['example-3-eap', 'example-4-experimental'], + }, + ); + }); + + test('a demotion names one model, so a newer one in that line surfaces again', () => { + // Deliberate: a demotion says "not this model", not "not this line". A line that + // comes back is worth seeing, which is the whole point of failing upward. The + // cost is that suppressing a variant has to be repeated when it is re-released. + const shortlist = (models: readonly ILanguageModelChatMetadataAndIdentifier[]) => buildModelPickerSections({ + models, + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'example-5.5': { label: 'Example 5.5', exists: true, demoted: true }, + 'example-1-lite-picker': { label: 'Example 1 Lite', exists: true, demoted: true }, + }, + showSuggested: true, + }).suggested.map(model => model.metadata.id).sort(); + + const retired = createModel('example-5.5', 'Example 5.5'); + const variant = createModel('example-1-lite-picker', 'Example 1 Lite'); + assert.deepStrictEqual( + { + demoted: shortlist([retired, variant]), + // A flagship on the line that was retired, and a re-release of the variant. + succeeded: shortlist([retired, variant, createModel('example-6', 'Example 6'), createModel('example-2-lite-picker', 'Example 2 Lite')]), + }, + { demoted: [], succeeded: ['example-2-lite-picker', 'example-6'] }, + ); + }); + + test('a model is paired with the faster twin the provider names by id', () => { + // The ids and names the provider actually uses for the pair. + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + // Ends in the suffix but has no twin, so it is a model in its own right. + const orphan = createModel('some-model-fast', 'Some Model (fast mode)'); + const variants = buildSpeedVariants([gpt, standard, fast, orphan]); + + assert.deepStrictEqual( + { + fromStandard: variants.get(standard.identifier)?.fast.metadata.id, + fromFast: variants.get(fast.identifier)?.standard.metadata.id, + unpaired: [gpt, orphan].map(model => variants.has(model.identifier)), + }, + { fromStandard: 'example-2.5-fast', fromFast: 'example-2.5', unpaired: [false, false] }, + ); + }); + + test('a pair takes one row, showing whichever twin is in use', () => { + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + const models = [gpt, standard, fast]; + const variants = buildSpeedVariants(models); + const names = (selected: string | undefined) => + collapseSpeedVariants(models, variants, selected).map(model => model.metadata.name); + + assert.deepStrictEqual( + { + neither: names(undefined), + standardSelected: names(standard.identifier), + fastSelected: names(fast.identifier), + }, + { + neither: ['GPT-5.5', 'Example 2.5'], + standardSelected: ['GPT-5.5', 'Example 2.5'], + // The twin in use is never hidden, however the pair is collapsed. + fastSelected: ['GPT-5.5', 'Example 2.5 (fast mode)'], + }, + ); + }); + + test('badges rank a retiring model over an offer over the settings a model was tuned to', () => { + const retiring = { ...gpt, metadata: { ...gpt.metadata, warningText: { model_pending_deprecation: 'Retiring soon.' } } }; + const promo = { ...claude, metadata: { ...claude.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const tuned = createConfigurableModel(); + const badge = (model: ILanguageModelChatMetadataAndIdentifier, values: IStringDictionary = {}, providerLabel?: string) => + getModelBadge(model, { configurationAccess: createConfigurationAccess(values), providerLabel }); + + assert.deepStrictEqual( + { + retiring: badge(retiring), + promo: badge(promo), + tuned: badge(tuned, { reasoningEffort: 'xhigh', contextSize: 1000000 }), + // Left at its defaults, so there is nothing to report. + untouched: badge(tuned), + provider: badge(gpt, {}, 'Ollama'), + plain: badge(gpt), + }, + { + retiring: { text: 'Retiring', tone: 'warning' }, + promo: { text: '25% off', tone: 'promo' }, + tuned: { text: 'Extra high \u00b7 1M', tone: 'selected' }, + untouched: undefined, + provider: { text: 'Ollama', tone: 'neutral' }, + plain: undefined, + }, + ); + }); + + test('curated models the account cannot reach are named so their unlock path shows', () => { + const controlModels: IStringDictionary = { + 'gpt-5-5': { label: 'GPT-5.5', featured: true, exists: true }, + 'example-opus-5': { label: 'Claude Opus 5', featured: true, exists: false }, + 'gpt-6': { label: 'GPT-6', featured: true, exists: false, minVSCodeVersion: '99.0.0' }, + 'hidden': { label: 'Not Featured', featured: false, exists: false }, + }; + const sections = buildModelPickerSections({ + models: [gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { + suggested: ['GPT-5.5'], + unavailable: [{ id: 'example-opus-5', needsUpdate: false }, { id: 'gpt-6', needsUpdate: true }], + }, + ); + }); + + test('an offer on a model this build is too old to run does not break the shortlist', () => { + // The promoted model is gated, so it is not among the selectable models. Reaching + // for it regardless used to put a hole in the shortlist and crash the sort. + const base = createModel('gpt-6', 'GPT-6'); + const gatedPromo = { ...base, metadata: { ...base.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const sections = buildModelPickerSections({ + models: [gatedPromo, gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'gpt-6': { label: 'GPT-6', featured: true, exists: true, minVSCodeVersion: '99.0.0' } }, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { suggested: ['GPT-5.5'], other: [], unavailable: [{ id: 'gpt-6', needsUpdate: true }] }, + ); + }); + + test('a model this build is too old to run is never offered as selectable', () => { + const controlModels: IStringDictionary = { + 'gpt-5-5': { label: 'GPT-5.5', featured: true, exists: true, minVSCodeVersion: '99.0.0' }, + }; + // Favourited and selected both name it, so it would reappear if any section kept it. + const sections = buildModelPickerSections({ + models: [gpt, claude], + selectedModelId: 'copilot/gpt-5-5', + recentModelIds: ['copilot/gpt-5-5'], + pinnedModelIds: ['copilot/gpt-5-5'], + controlModels: { ...controlModels, 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true } }, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + pinned: sections.pinned.map(model => model.metadata.name), + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { + pinned: [], + suggested: [], + other: ['Claude Sonnet 5'], + unavailable: [{ id: 'gpt-5-5', needsUpdate: true }], + }, + ); + }); + + test('surfaces that cannot act on locked models do not advertise them', () => { + const sections = buildModelPickerSections({ + models: [gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'example-opus-5': { label: 'Claude Opus 5', featured: true, exists: false } }, + showSuggested: true, + }); + assert.deepStrictEqual(sections.unavailable, []); + }); + + test('context is extended only at the largest configured window', () => { + const model = createConfigurableModel(); + const read = (values: IStringDictionary) => { + const property = getModelConfigProperty(model, createConfigurationAccess(values), MODEL_CONFIG_GROUP_CONTEXT)!; + return isExtendedContext(property); + }; + assert.deepStrictEqual( + { default: read({}), standard: read({ contextSize: 264000 }), extended: read({ contextSize: 1000000 }) }, + { default: false, standard: false, extended: true }, + ); + }); + + test('the configuration summary names only what was changed from the defaults', () => { + const model = createConfigurableModel(); + assert.deepStrictEqual( + { + defaults: getModelConfigSummary(model, createConfigurationAccess()), + bothChanged: getModelConfigSummary(model, createConfigurationAccess({ reasoningEffort: 'xhigh', contextSize: 1000000 })), + oneChanged: getModelConfigSummary(model, createConfigurationAccess({ contextSize: 1000000 })), + noSchema: getModelConfigSummary(gpt, createConfigurationAccess()), + }, + { defaults: undefined, bothChanged: 'Extra high · 1M', oneChanged: '1M', noSchema: undefined }, + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts index 4558d92cc9d27b..1af5ff3f83393f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts @@ -38,8 +38,9 @@ function createCodexItem(type: AgentSessionProviders.Codex | AgentSessionProvide }; } -function getMarkdownValue(value: string | IMarkdownString | HTMLElement | undefined): string | undefined { - return typeof value === 'string' ? value : value instanceof HTMLElement ? value.textContent ?? undefined : value?.value; +function getMarkdownValue(value: string | IMarkdownString | HTMLElement | (() => HTMLElement) | undefined): string | undefined { + const resolved = typeof value === 'function' ? value() : value; + return typeof resolved === 'string' ? resolved : resolved instanceof HTMLElement ? resolved.textContent ?? undefined : resolved?.value; } interface IAvailabilityInputs { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts new file mode 100644 index 00000000000000..d1b4ce572b843d --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts @@ -0,0 +1,503 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../base/common/collections.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { localize } from '../../../../../nls.js'; +import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../../../../platform/agentHost/common/autoModeTiers.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IContextViewDelegate, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; +import { NullOpenerService } from '../../../../../platform/opener/test/common/nullOpenerService.js'; +import { StateType } from '../../../../../platform/update/common/update.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelProviderDescriptor, ILanguageModelsService, IModelControlEntry } from '../../../../contrib/chat/common/languageModels.js'; +import { IModelConfigurationAccess } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { ModelPickerAutoRow } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.js'; +import { IPricingDisclosure, ModelCard } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerCard.js'; +import { ITabbedModelPickerContext, TabbedModelPicker } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.js'; +import { IModelPickerProviderPlaceholder } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; +import '../../../../contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css'; + +const EXTENSION = new ExtensionIdentifier('fixture.models'); + +interface IFixtureModelOptions { + readonly vendor?: string; + readonly category?: string; + readonly priceCategory?: string; + readonly detail?: string; + readonly effortValues?: readonly string[]; + readonly effortLabels?: readonly string[]; + readonly effortDescriptions?: readonly string[]; + readonly effortTitle?: string; + readonly effortDefault?: string; + readonly contextLabels?: readonly string[]; + readonly costs?: boolean; + readonly isBYOK?: boolean; + readonly promoDiscount?: number; + readonly retiring?: boolean; + readonly byokModelIdentifier?: string; + readonly modelGroupId?: string; +} + +function createModel(id: string, name: string, options: IFixtureModelOptions = {}): ILanguageModelChatMetadataAndIdentifier { + const vendor = options.vendor ?? 'copilot'; + const properties: NonNullable['properties'] = {}; + if (options.effortValues) { + properties.reasoningEffort = { + type: 'string', + title: options.effortTitle, + group: 'navigation', + enum: [...options.effortValues], + enumItemLabels: options.effortLabels ? [...options.effortLabels] : undefined, + enumDescriptions: options.effortDescriptions + ? [...options.effortDescriptions] + : ['Fastest, least thorough', 'Balanced reasoning and speed', 'Slowest, most thorough'], + default: options.effortDefault ?? options.effortValues[1], + }; + } + if (options.contextLabels) { + properties.contextSize = { + type: 'number', + group: 'tokens', + enum: [264000, 1000000], + enumItemLabels: [...options.contextLabels], + // Real providers describe the standard window as "Default", which says nothing + // the segment does not already say. + enumDescriptions: ['Default', 'Extended context window'], + default: 264000, + }; + } + return { + identifier: `${vendor}/${id}`, + metadata: upcastPartial({ + extension: EXTENSION, + id, + name, + vendor, + version: '1.0', + family: id, + detail: options.detail, + category: options.category, + priceCategory: options.priceCategory, + isBYOK: options.isBYOK, + byokModelIdentifier: options.byokModelIdentifier, + modelGroup: options.modelGroupId ? { id: options.modelGroupId } : undefined, + promo: options.promoDiscount ? { id: 'promo', discountPercent: options.promoDiscount, message: 'Discounted for a limited time.' } : undefined, + warningText: options.retiring ? { model_pending_deprecation: 'This model is retiring soon.' } : undefined, + maxInputTokens: 264000, + maxOutputTokens: 64000, + isDefaultForLocation: {}, + inputCost: options.costs ? 40 : undefined, + outputCost: options.costs ? 200 : undefined, + cacheCost: options.costs ? 4 : undefined, + cacheWriteCost: options.costs ? 50 : undefined, + longContextInputCost: options.costs ? 80 : undefined, + longContextOutputCost: options.costs ? 400 : undefined, + longContextCacheCost: options.costs ? 8 : undefined, + longContextCacheWriteCost: options.costs ? 100 : undefined, + configurationSchema: Object.keys(properties).length ? { properties } : undefined, + }), + }; +} + +// Built from the runtime's own routing profiles so the fixture cannot drift from the +// values the Auto model actually offers. +const AUTO_MODEL = createModel('auto', 'Auto', { + detail: '10% off', + effortTitle: localize('copilot.modelAutoTier.title', "Optimize for"), + effortValues: [...autoModeTiers], + effortLabels: autoModeTiers.map(getAutoModeTierLabel), + effortDescriptions: autoModeTiers.map(tier => getAutoModeTierDescription(tier) ?? ''), + effortDefault: defaultAutoModeTier, +}); + +const COPILOT_MODELS = [ + createModel('gemini-3-1-pro', 'Gemini 3.1 Pro', { + category: 'powerful', priceCategory: 'medium', costs: true, + effortValues: ['minimal', 'medium', 'high'], effortLabels: ['Minimal', 'Medium', 'High'], + contextLabels: ['264K', '1M'], + }), + createModel('gpt-5-5', 'GPT-5.5', { + category: 'powerful', priceCategory: 'high', costs: true, + effortValues: ['low', 'medium', 'xhigh'], effortLabels: ['Low', 'Medium', 'Extra high'], + contextLabels: ['264K', '1M'], + }), + createModel('claude-sonnet-5', 'Claude Sonnet 5', { category: 'powerful', priceCategory: 'medium', costs: true }), + createModel('gpt-5-3-codex', 'GPT-5.3-Codex', { category: 'versatile', priceCategory: 'low' }), + createModel('gemini-3-5-flash', 'Gemini 3.5 Flash', { category: 'lightweight', priceCategory: 'low' }), +]; + +/** Copilot models plus the states a row can advertise: an offer and a retirement. */ +const COPILOT_NOTICE_MODELS = [ + ...COPILOT_MODELS, + createModel('gpt-5-1', 'GPT-5.1', { category: 'versatile', priceCategory: 'low', promoDiscount: 25 }), + createModel('gpt-4-turbo', 'GPT-4 Turbo', { category: 'versatile', priceCategory: 'medium', retiring: true }), +]; + +/** The full effort ladder a real model can publish, where the labels vary in width. */ +const MANY_EFFORT_MODEL = createModel('gpt-5-6-terra', 'GPT-5.6 Terra', { + category: 'powerful', priceCategory: 'medium', costs: true, + effortValues: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + effortLabels: ['None', 'Low', 'Medium', 'High', 'Extra High', 'Max'], + contextLabels: ['272K', '1M'], +}); + +const OLLAMA_MODELS = [ + createModel('llama-3-70b', 'Llama 3 70B', { vendor: 'ollama', isBYOK: true }), + createModel('mistral-large', 'Mistral Large', { vendor: 'ollama', isBYOK: true }), +]; + +const OPENAI_MODELS = [ + createModel('gpt-4o', 'GPT-4o', { vendor: 'openai', isBYOK: true }), +]; + +const ANTHROPIC_MODELS = [ + createModel('example-opus-4', 'Example Opus 4', { vendor: 'anthropic', isBYOK: true }), +]; + +const GOOGLE_MODELS = [ + createModel('gemini-2-flash', 'Gemini 2 Flash', { vendor: 'google', isBYOK: true }), +]; + +/** A model the provider also offers at a second speed, named by an id suffix. */ +const SPEED_VARIANT_MODELS = [ + createModel('example-2.5', 'Example 2.5', { category: 'powerful', priceCategory: 'high', costs: true }), + createModel('example-2.5-fast', 'Example 2.5 (fast mode)', { category: 'powerful', priceCategory: 'very_high', costs: true }), +]; + +const COPILOT_ONLY_MODELS = [AUTO_MODEL, ...COPILOT_MODELS]; + +/** How the Copilot agent host relays models: its own vendor, BYOK stamped on everything. */ +const RELAYED_MODELS = [ + createModel('auto', 'Auto', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'copilot', detail: '10% off', effortValues: [...autoModeTiers], effortLabels: autoModeTiers.map(getAutoModeTierLabel), effortDefault: defaultAutoModeTier }), + createModel('gpt-5-5', 'GPT-5.5', { vendor: 'agent-host-copilotcli', isBYOK: true, byokModelIdentifier: 'copilot/gpt-5-5', modelGroupId: 'copilot', category: 'powerful', priceCategory: 'high' }), + createModel('claude-sonnet-5', 'Claude Sonnet 5', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'copilot', category: 'powerful' }), + createModel('llama-3-70b', 'Llama 3 70B', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'ollama' }), +]; +const ALL_MODELS = [...COPILOT_ONLY_MODELS, ...OLLAMA_MODELS]; + +const CONTROL_MODELS: IStringDictionary = { + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', featured: true, exists: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', featured: true, exists: true }, +}; + +/** Adds curated models the account cannot reach, plus one gated behind a newer build. */ +const CONTROL_MODELS_WITH_LOCKED: IStringDictionary = { + ...CONTROL_MODELS, + 'example-locked-5': { label: 'Example Locked 5', featured: true, exists: false }, + 'gpt-6': { label: 'GPT-6', featured: true, exists: false, minVSCodeVersion: '99.0.0' }, +}; + +/** In-memory model configuration so the fixture's cards and tiers are interactive. */ +function createConfigurationAccess(): IModelConfigurationAccess { + const values = new Map>(); + return { + getModelConfiguration: modelId => values.get(modelId), + setModelConfiguration: async (modelId, next) => { values.set(modelId, { ...values.get(modelId), ...next }); }, + getModelConfigurationActions: () => [], + }; +} + +/** A disclosure backed by a plain flag, so a fixture can render either state. */ +function createPricingDisclosure(disposableStore: DisposableStore, expanded: boolean): IPricingDisclosure { + const emitter = disposableStore.add(new Emitter()); + let current = expanded; + return { + isExpanded: () => current, + setExpanded: next => { current = next; emitter.fire(); }, + onDidChange: emitter.event, + }; +} + +function createLanguageModelsService(): ILanguageModelsService { + // Only the fields the picker reads. The rest of the descriptor never comes up here. + const vendor = (vendor: string, displayName: string, isDefault: boolean) => + upcastPartial({ vendor, displayName, isDefault }); + return upcastPartial({ + getVendors: () => [ + vendor('copilot', 'GitHub Copilot', true), + vendor('ollama', 'Ollama', false), + vendor('openai', 'OpenAI', false), + ], + getLanguageModelGroups: () => [], + }); +} + +/** Renders the popup inline in the fixture container instead of a floating context view. */ +function createInlineContextViewService(container: HTMLElement, disposables: ComponentFixtureContext['disposableStore']): IContextViewService { + let activeHost: HTMLElement | undefined; + let activeRender: { dispose(): void } | undefined; + const hide = () => { + activeRender?.dispose(); + activeHost?.remove(); + activeRender = undefined; + activeHost = undefined; + }; + disposables.add({ dispose: hide }); + return upcastPartial({ + showContextView: (delegate: IContextViewDelegate) => { + hide(); + activeHost = document.createElement('div'); + container.appendChild(activeHost); + const rendered = delegate.render(activeHost); + activeRender = rendered ?? undefined; + return { close: hide }; + }, + hideContextView: hide, + getContextViewElement: () => container, + layout: () => { }, + anchorAlignment: 0, + _serviceBrand: undefined, + }); +} + +function setupContainer(container: HTMLElement, width: number): void { + container.classList.add('monaco-workbench'); + container.style.width = `${width}px`; + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-editor-background)'; +} + +interface IPickerFixtureOptions { + readonly selectedModelId?: string; + readonly pinnedModelIds?: readonly string[]; + /** Opens the detail card for the model whose row label matches, as a chevron click would. */ + readonly openCardFor?: string; + /** Providers with no models, which show a welcome body instead of a list. */ + readonly providerPlaceholders?: readonly IModelPickerProviderPlaceholder[]; + /** Starts on this destination, matched against the tab label. */ + readonly initialTabLabel?: string; + /** Overrides the models offered, e.g. to show the picker without any added models. */ + readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Opens search, which replaces the tab strip with the filter field. */ + readonly search?: boolean; + /** Expands the collapsed "Other Models" section. */ + readonly expandOther?: boolean; + /** Curated models the account cannot select, which offer an unlock path instead. */ + readonly controlModels?: IStringDictionary; + /** The plan the account is on, which decides whether locked models offer upgrade or admin. */ + readonly entitlement?: ChatEntitlement; + /** Settings to apply per model identifier, so rows can show what they were tuned to. */ + readonly configured?: IStringDictionary>; +} + +async function renderPicker(context: ComponentFixtureContext, options: IPickerFixtureOptions = {}): Promise { + const { container, disposableStore } = context; + setupContainer(container, options.openCardFor ? 700 : 360); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: registration => { + registerWorkbenchServices(registration); + registration.defineInstance(ILayoutService, upcastPartial({ + getContainer: () => container.ownerDocument.body, + mainContainer: container.ownerDocument.body, + activeContainer: container.ownerDocument.body, + onDidChangeActiveContainer: Event.None, + onDidAddContainer: Event.None, + onDidLayoutMainContainer: Event.None, + onDidLayoutActiveContainer: Event.None, + onDidLayoutContainer: Event.None, + })); + registration.defineInstance(IContextViewService, createInlineContextViewService(container, disposableStore)); + registration.defineInstance(ILanguageModelsService, createLanguageModelsService()); + registration.defineInstance(IChatEntitlementService, upcastPartial({ entitlement: options.entitlement ?? ChatEntitlement.Free })); + // The shared harness discards writes, so the picker cannot remember anything. + registration.defineInstance(IStorageService, disposableStore.add(new InMemoryStorageService())); + }, + }); + + // The picker opens upward from its chip, so its height comes from the space above the + // anchor. Stand the anchor where the chat input's chip sits, or the list measures flat. + const anchor = document.createElement('div'); + anchor.style.position = 'fixed'; + anchor.style.bottom = '8px'; + anchor.style.left = '8px'; + anchor.style.width = '120px'; + anchor.style.height = '22px'; + container.appendChild(anchor); + + const picker = disposableStore.add(instantiationService.createInstance(TabbedModelPicker)); + const configurationAccess = createConfigurationAccess(); + for (const [modelId, values] of Object.entries(options.configured ?? {})) { + void configurationAccess.setModelConfiguration(modelId, values); + } + const pickerContext: ITabbedModelPickerContext = { + models: options.models ?? ALL_MODELS, + selectedModelId: options.selectedModelId ?? 'copilot/gpt-5-5', + recentModelIds: ['copilot/gpt-5-3-codex', 'copilot/gemini-3-5-flash'], + pinnedModelIds: options.pinnedModelIds ?? ['copilot/claude-sonnet-5'], + controlModels: options.controlModels ?? CONTROL_MODELS, + configurationAccess, + isUBB: true, + showManageModels: true, + onSelect: () => { }, + onTogglePin: () => { }, + onManageModels: () => { }, + onConfigurationChanged: () => { }, + unavailableContext: { + show: true, + currentVSCodeVersion: '1.100.0', + manageSettingsUrl: 'https://github.com/settings/copilot', + updateStateType: StateType.Idle, + }, + onUnavailableLinkClick: () => { }, + providerPlaceholders: options.providerPlaceholders ?? [], + cacheBreakHint: undefined, + }; + picker.show(anchor, pickerContext); + + if (options.expandOther) { + [...container.querySelectorAll('.monaco-list-row.action')] + .find(row => row.textContent?.includes('Other Models'))?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.search) { + container.querySelector('.tabbed-action-list-tabbar-action[data-id="search"]')?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.initialTabLabel) { + const tab = [...container.querySelectorAll('.chat-model-picker-tabbar .monaco-button')] + .find(candidate => candidate.ariaLabel === options.initialTabLabel); + tab?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.openCardFor) { + const row = [...container.querySelectorAll('.monaco-list-row.action')] + .find(candidate => candidate.textContent?.includes(options.openCardFor!)); + row?.querySelector('.action-list-submenu-indicator.has-submenu')?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } +} + +function renderCard(context: ComponentFixtureContext, model: ILanguageModelChatMetadataAndIdentifier, extendedContext: boolean, pricingExpanded = false): void { + const { container, disposableStore } = context; + setupContainer(container, 320); + + const configurationAccess = createConfigurationAccess(); + if (extendedContext) { + void configurationAccess.setModelConfiguration(model.identifier, { contextSize: 1000000 }); + } + + const card = disposableStore.add(new ModelCard({ + model, + configurationAccess, + isUBB: true, + openerService: NullOpenerService, + pricingDisclosure: createPricingDisclosure(disposableStore, pricingExpanded), + })); + + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + wrapper.appendChild(card.element); + container.appendChild(wrapper); +} + +function renderAutoRow(context: ComponentFixtureContext, enabled: boolean): void { + const { container, disposableStore } = context; + setupContainer(container, 320); + + const row = disposableStore.add(new ModelPickerAutoRow({ + autoModel: AUTO_MODEL, + configurationAccess: createConfigurationAccess(), + isEnabled: () => enabled, + onToggle: () => { }, + })); + + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + wrapper.appendChild(row.element); + container.appendChild(wrapper); +} + +export default defineThemedFixtureGroup({ path: 'chat/input/tabbedModelPicker' }, { + Picker: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS }) }), + PickerWithAddedModels: defineComponentFixture({ render: context => renderPicker(context) }), + PickerAddedModelsTab: defineComponentFixture({ + render: context => renderPicker(context, { initialTabLabel: 'Ollama' }), + }), + PickerWithManyProviders: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...ALL_MODELS, ...OPENAI_MODELS, ...ANTHROPIC_MODELS, ...GOOGLE_MODELS], + initialTabLabel: 'Ollama', + }), + }), + PickerAddedModelsPinned: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...ALL_MODELS, ...OPENAI_MODELS], + pinnedModelIds: ['ollama/llama-3-70b', 'openai/gpt-4o'], + initialTabLabel: 'Ollama', + }), + }), + PickerWithAutoSelected: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, selectedModelId: 'copilot/auto' }) }), + PickerAutoOnlyPlan: defineComponentFixture({ + render: context => renderPicker(context, { + models: [AUTO_MODEL], + selectedModelId: 'copilot/auto', + controlModels: CONTROL_MODELS_WITH_LOCKED, + }), + }), + PickerRelayedByHost: defineComponentFixture({ + render: context => renderPicker(context, { models: RELAYED_MODELS, selectedModelId: 'agent-host-copilotcli/gpt-5-5' }), + }), + PickerLockedModels: defineComponentFixture({ + render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, controlModels: CONTROL_MODELS_WITH_LOCKED }), + }), + PickerLockedModelsBusiness: defineComponentFixture({ + render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, controlModels: CONTROL_MODELS_WITH_LOCKED, entitlement: ChatEntitlement.Business }), + }), + PickerBadges: defineComponentFixture({ + render: context => renderPicker(context, { models: [AUTO_MODEL, ...COPILOT_NOTICE_MODELS], expandOther: true }), + }), + PickerConfiguredModels: defineComponentFixture({ + render: context => renderPicker(context, { + models: COPILOT_ONLY_MODELS, + configured: { + 'copilot/gpt-5-5': { reasoningEffort: 'xhigh', contextSize: 1000000 }, + 'copilot/gemini-3-1-pro': { reasoningEffort: 'high' }, + }, + }), + }), + PickerSearch: defineComponentFixture({ render: context => renderPicker(context, { search: true }) }), + PickerWithCard: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, openCardFor: 'GPT-5.5' }) }), + PickerWelcome: defineComponentFixture({ + render: context => renderPicker(context, { + models: [], + providerPlaceholders: [{ vendor: 'copilot', label: 'GitHub Copilot', message: 'Sign in to see available models.', action: { label: 'Sign in', run: () => { } } }], + }), + }), + CardStandardContext: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], false) }), + CardPricingExpanded: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], false, true) }), + PickerSpeedVariants: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...COPILOT_ONLY_MODELS, ...SPEED_VARIANT_MODELS], + expandOther: true, + openCardFor: 'Example 2.5', + }), + }), + /** The faster twin selected outright, as `chat.defaultModel` set to its id does. */ + PickerSpeedVariantsFastSelected: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...COPILOT_ONLY_MODELS, ...SPEED_VARIANT_MODELS], + selectedModelId: 'copilot/example-2.5-fast', + openCardFor: 'Example 2.5 (fast mode)', + }), + }), + CardExtendedContext: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], true) }), + CardManyEffortValues: defineComponentFixture({ render: context => renderCard(context, MANY_EFFORT_MODEL, false) }), + CardWithoutConfiguration: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[2], false) }), + AutoRowOff: defineComponentFixture({ render: context => renderAutoRow(context, false) }), + AutoRowOn: defineComponentFixture({ render: context => renderAutoRow(context, true) }), +}); From 47c84b8b1ae60f85d38f119beb2e4cf1922b8ce1 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 3 Sep 2026 14:19:19 -0700 Subject: [PATCH 41/44] Agent Host: stop the reconnecting banner nagging on a flapping transport (#334348) * Agent Host: stop the reconnecting banner nagging on a flapping transport A tunnel relay that drops and restores the transport every few seconds made the banner appear on a session that worked fine throughout. Two causes. The outage start time lived in a cached derived. While the host is connected nothing reads the reconnecting state, so that derived lost its observers while its cache survived them; the next outage on the same session inherited the previous outage's start time and showed the banner immediately. It is now an explicit value maintained by an autorun, which runs whether or not anything is rendering the reconnecting state. The delay before the banner appears was also shorter than a routine self-healed reconnect. Such a reconnect preserves session state and the user would not otherwise notice it, so the threshold now outlasts one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep the outage-start derived alive instead of hand-maintaining it recomputeInitiallyAndOnChange keeps the cached derived observed, so it recomputes when the host reconnects rather than retaining a start time nothing was left to invalidate. Restores the session keying and drops the autorun that replaced it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/sessionRemoteConnection.ts | 23 ++++++-- .../test/browser/chatGroupsView.test.ts | 52 ++++++++++++++----- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index cadf62c0953f61..e9b094ecb2ac3a 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -17,7 +17,13 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js'; import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; -const RECONNECTING_BANNER_DELAY = 1_000; +/** + * How long a host must stay unreachable before the banner appears. + * + * Sized to outlast a transport blip the protocol client heals by itself: such a + * reconnect preserves session state, so the user would not otherwise notice it. + */ +const RECONNECTING_BANNER_DELAY = 5_000; function isSameRemoteConnectionStatus(a: SessionRemoteConnectionStatus | undefined, b: SessionRemoteConnectionStatus | undefined): boolean { if (!a || !b) { @@ -76,6 +82,17 @@ export class SessionRemoteConnection extends Disposable { && this._attempt.read(reader) === undefined; }); + /** + * When the current outage began, or `undefined` while the host is reachable. + * + * Recomputed eagerly so the cache cannot outlive its observers: nothing reads + * the reconnecting state while the host is connected, and a derived that + * stops being observed keeps its last value without ever recomputing it, so a + * later outage would inherit the previous one's start time and skip the delay. + * + * Keyed by session only to guard future reuse: a ChatGroupView is currently + * created per session, so the cache cannot outlive the session it belongs to. + */ private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => { const session = this._session.read(reader); const status = this._getEffectiveStatus(reader); @@ -83,10 +100,8 @@ export class SessionRemoteConnection extends Disposable { if (!session || status?.kind !== 'reconnecting' || attempt?.kind === 'active') { return undefined; } - // Keyed by session only to guard future reuse: a ChatGroupView is currently - // created per session, so the cache cannot outlive the session it belongs to. return last?.session === session ? last : { session, since: Date.now() }; - }); + }).recomputeInitiallyAndOnChange(this._store); private readonly _reconnectingBannerVisible = derived(this, reader => { const reconnecting = this._reconnectingSince.read(reader); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index f91b0ad60e7e53..1bdf3ed8f92c68 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -1087,7 +1087,9 @@ suite('Sessions - ChatGroupsView', () => { remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); remoteConnectionStatus.set({ kind: 'connected' }, undefined); - await timeout(1_000); + // Past the delay, so this proves the settled connection suppresses the + // banner rather than the threshold simply not having elapsed. + await timeout(6_000); assert.deepStrictEqual(readBanner(view), { visible: false, message: 'This chat is read-only', action: undefined }); }); @@ -1102,9 +1104,9 @@ suite('Sessions - ChatGroupsView', () => { const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'reconnecting' }); view.setSession(session, options); - await timeout(500); + await timeout(3_000); chat.status.set(SessionStatus.Error, undefined); - await timeout(500); + await timeout(3_000); assert.deepStrictEqual(readBanner(view), { visible: true, @@ -1119,16 +1121,16 @@ suite('Sessions - ChatGroupsView', () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); const provider = new TestAgentHostProvider(); sessionsProvidersService.provider = provider; - const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 6_000 }); + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 12_000 }); view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(5_500); const banner = readBanner(view); view.element.querySelector('.session-readonly-banner-action-link')?.click(); assert.deepStrictEqual({ banner, reconnectNowCalls: provider.reconnectNowCalls }, { - banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' }, reconnectNowCalls: 1, }); }); @@ -1139,21 +1141,47 @@ suite('Sessions - ChatGroupsView', () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); const provider = new TestAgentHostProvider(); sessionsProvidersService.provider = provider; - const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 7_000 }); + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 13_000 }); view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(5_500); const beforeTick = readBanner(view); await timeout(1_000); assert.deepStrictEqual({ beforeTick, afterTick: readBanner(view) }, { - beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 6s', action: 'Try Now' }, - afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 8s', action: 'Try Now' }, + afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' }, }); }); }); + test('stays quiet while a flapping transport keeps healing itself', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'connected' }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + // Every outage heals well inside the delay, so none is worth a banner. + const banners: boolean[] = []; + for (let i = 0; i < 5; i++) { + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + await timeout(2_100); + banners.push(readBanner(view).visible); + remoteConnectionStatus.set({ kind: 'connected' }, undefined); + await timeout(3_700); + banners.push(readBanner(view).visible); + } + + assert.deepStrictEqual(banners, [false, false, false, false, false, false, false, false, false, false]); + }); + }); + test('shows a plain reconnecting banner while a reconnect attempt is in flight', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); @@ -1163,7 +1191,7 @@ suite('Sessions - ChatGroupsView', () => { view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(6_000); assert.deepStrictEqual(readBanner(view), { visible: true, @@ -1196,7 +1224,7 @@ suite('Sessions - ChatGroupsView', () => { } remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); - await timeout(1_000); + await timeout(6_000); assert.deepStrictEqual({ connectCalls: provider.connectCalls, banner: readBanner(view) }, { connectCalls: 1, From 61fc6318881a456d1519cee1d15412cbd1abddf3 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Tue, 1 Sep 2026 09:06:18 +0000 Subject: [PATCH 42/44] [cherry-pick] Fix markdown inline comment hover background --- .../markdown-editor-src/markdownEditor.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css index 3771a8cd9de0cd..b5ea146cdf09e3 100644 --- a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css +++ b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css @@ -20,3 +20,16 @@ body { width: 100%; overflow: scroll; } + +.md-comment-widget { + background: + linear-gradient(var(--vscode-menu-background), var(--vscode-menu-background)), + var(--vscode-editor-background); +} + +.md-comment-widget:hover, +.md-comment-widget--hover { + background: + linear-gradient(var(--vscode-list-hoverBackground), var(--vscode-list-hoverBackground)), + var(--vscode-editor-background); +} From a04df6e6dcf8e84e56cc98f6795708bad2a5e935 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:16:30 -0700 Subject: [PATCH 43/44] remove example from mock-policy-server (#334361) --- scripts/mock-policy-server/endpoints.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index 03c4bfbf94c1d2..88a9048fa6f824 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -95,17 +95,6 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } }, - { - id: 'allow-auto-only', - label: 'Allow auto-approval only', - description: 'Blocks full allow-all bypass but still permits advisory auto-approval (LLM safety recommendations with normal prompt paths).', - status: 200, - body: { - permissions: { - disableBypassPermissionsMode: 'allow-auto-only' - } - } - }, { id: 'deny-dangerous-commands', label: 'Deny dangerous shell/file operations', From 68a1501847cb087c2ec94340bc161f75aa4ccfe3 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:17:35 +0200 Subject: [PATCH 44/44] Agents - enable switching branches for folder sessions (#334354) * Initial implementation of the operation * Fix condition to check session state and draft usage Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refactor operation key and lane handling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agentHostChangesetOperationService.ts | 1 + .../agentHost/common/agentHostGitService.ts | 2 + .../common/meta/agentCheckoutOperationMeta.ts | 25 ++ .../agentHostChangesetOperationService.ts | 73 ++-- .../node/agentHostCheckoutOperationHandler.ts | 83 +++++ .../agentHostCheckoutOperationProvider.ts | 58 ++++ .../agentHost/node/agentHostContributions.ts | 2 + .../agentHost/node/agentHostGitService.ts | 4 + .../node/shared/worktreeIsolation.ts | 10 +- .../test/common/sessionTestHelpers.ts | 1 + ...agentHostChangesetOperationService.test.ts | 44 +++ .../agentHostCheckoutOperationHandler.test.ts | 107 ++++++ ...agentHostCheckoutOperationProvider.test.ts | 73 ++++ .../agentHostCommitOperationHandler.test.ts | 1 + ...HostDiscardChangesOperationHandler.test.ts | 1 + .../agentHostGitService.integrationTest.ts | 9 + ...entHostPullRequestOperationHandler.test.ts | 1 + .../agentHost/test/node/agentService.test.ts | 6 +- .../agentHost/test/node/copilotAgent.test.ts | 1 + .../test/node/copilotGitProject.test.ts | 1 + .../node/shared/worktreeIsolation.test.ts | 11 +- .../common/agentHostSessionsProvider.ts | 2 + .../contrib/changes/browser/changesActions.ts | 3 +- .../changes/browser/changesViewService.ts | 5 +- .../test/browser/changesActions.test.ts | 9 +- .../test/browser/changesViewService.test.ts | 21 ++ .../browser/agentHostSessionChangesets.ts | 7 +- .../browser/agentHostSessionConfigPicker.ts | 108 +++++- .../browser/baseAgentHostSessionsProvider.ts | 66 +++- .../agentHostSessionConfigPicker.test.ts | 325 +++++++++++++++++- .../agentHostSessionChangesets.test.ts | 20 ++ .../localAgentHostSessionsProvider.test.ts | 106 +++++- .../services/sessions/common/session.ts | 7 +- 33 files changed, 1102 insertions(+), 91 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index ae9a30f2bc5604..5b0096f7950696 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -13,6 +13,7 @@ import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } f export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); export const AGENT_HOST_MERGE_CHANGESET_OPERATION_ID = 'merge'; +export const AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID = 'checkout'; export const AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID = 'commit'; export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 14694fc1755b9c..3f2c4848aaec99 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -256,6 +256,8 @@ export interface IAgentHostGitService { branchExists(repositoryRoot: URI, branchName: string): Promise; /** Creates a new branch and optionally checks it out while preserving the working tree. */ createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise; + /** Checks out an existing local branch. */ + checkout(workingDirectory: URI, treeish: string): Promise; /** * Returns true when the working tree has any tracked, staged, or * untracked changes. Used by archive cleanup to skip removing a diff --git a/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts new file mode 100644 index 00000000000000..2a6dfd36d4663e --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +interface IHasCheckoutOperationMeta { + readonly _meta?: Record; +} + +const TREEISH_META_KEY = 'treeish'; + +/** Creates request metadata for a Checkout changeset operation. */ +export function checkoutOperationMeta(treeish: string): Record { + return { [TREEISH_META_KEY]: treeish }; +} + +/** Reads and validates the treeish requested by a Checkout changeset operation. */ +export function readCheckoutOperationTreeish(source: IHasCheckoutOperationMeta): string | undefined { + const meta = source._meta; + if (!meta) { + return undefined; + } + const treeish = meta[TREEISH_META_KEY]; + return typeof treeish === 'string' && treeish.length > 0 ? treeish : undefined; +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 6ff134ce86a72b..2ad72f13b7d39c 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { stableStringify } from '../../../base/common/objects.js'; import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -25,6 +26,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA private readonly _handlerRegistrations = this._register(new DisposableMap()); private readonly _changesetOperationHandlers = new Map(); private readonly _inFlightOperations = new Map>(); + private readonly _operationLanes = new Map>(); constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -253,47 +255,62 @@ export class AgentHostChangesetOperationService extends Disposable implements IA handler: IChangesetOperationHandler, params: InvokeChangesetOperationParams, ): Promise { - const operationKey = `${params.channel}\x00${params.operationId}\x00${JSON.stringify(params.target ?? null)}`; + const operationLane = `${params.channel}\x00${params.operationId}`; + const operationKey = `${operationLane}\x00${stableStringify({ target: params.target ?? null, _meta: params._meta ?? null })}`; const inFlightOperationResult = this._inFlightOperations.get(operationKey); - if (inFlightOperationResult) { + if (inFlightOperationResult && this._operationLanes.get(operationLane) === inFlightOperationResult) { return inFlightOperationResult; } + const runningOperation = this._operationLanes.get(operationLane); + const operationPromise = runningOperation + ? runningOperation.then( + () => this._executeChangesetOperation(handler, params), + () => this._executeChangesetOperation(handler, params), + ) + : this._executeChangesetOperation(handler, params); + this._operationLanes.set(operationLane, operationPromise); + this._inFlightOperations.set(operationKey, operationPromise); + + const clearOperation = () => { + if (this._inFlightOperations.get(operationKey) === operationPromise) { + this._inFlightOperations.delete(operationKey); + } + if (this._operationLanes.get(operationLane) === operationPromise) { + this._operationLanes.delete(operationLane); + } + }; + void operationPromise.then(clearOperation, clearOperation); + + return operationPromise; + } + + private async _executeChangesetOperation(handler: IChangesetOperationHandler, params: InvokeChangesetOperationParams): Promise { this._stateManager.dispatchServerAction(params.channel, { type: ActionType.ChangesetOperationStatusChanged, operationId: params.operationId, status: ChangesetOperationStatus.Running, }); - const operationPromise = handler.invoke(params, CancellationToken.None) - .then(result => { - this._stateManager.dispatchServerAction(params.channel, { - type: ActionType.ChangesetOperationStatusChanged, - operationId: params.operationId, - status: ChangesetOperationStatus.Idle, - }); - - return result; - }) - .catch((error) => { - this._stateManager.dispatchServerAction(params.channel, { - type: ActionType.ChangesetOperationStatusChanged, - operationId: params.operationId, - status: ChangesetOperationStatus.Error, - error: toChangesetOperationError(error), - }); - - throw error; - }) - .finally(() => { - if (this._inFlightOperations.get(operationKey) === operationPromise) { - this._inFlightOperations.delete(operationKey); - } + try { + const result = await handler.invoke(params, CancellationToken.None); + this._stateManager.dispatchServerAction(params.channel, { + type: ActionType.ChangesetOperationStatusChanged, + operationId: params.operationId, + status: ChangesetOperationStatus.Idle, }); - this._inFlightOperations.set(operationKey, operationPromise); + return result; + } catch (error) { + this._stateManager.dispatchServerAction(params.channel, { + type: ActionType.ChangesetOperationStatusChanged, + operationId: params.operationId, + status: ChangesetOperationStatus.Error, + error: toChangesetOperationError(error), + }); - return operationPromise; + throw error; + } } private _registerChangesetOperationHandler(operationId: string, handler: IChangesetOperationHandler): IDisposable { diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts new file mode 100644 index 00000000000000..7c69a95398015b --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { readCheckoutOperationTreeish } from '../common/meta/agentCheckoutOperationMeta.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import type { SessionState } from '../common/state/sessionState.js'; + +export class AgentHostCheckoutOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_CHECKOUT = AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID; + + constructor( + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + private readonly _onCheckedOut: (sessionKey: string) => void, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ILogService private readonly _logService: ILogService, + ) { } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed || parsed.kind !== ChangesetKind.Uncommitted) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not an uncommitted changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + + const sessionUri = parsed.sessionUri; + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + const workingDirectoryValue = sessionState.workingDirectories?.[0]; + if (!workingDirectoryValue) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + const treeish = readCheckoutOperationTreeish(params); + if (!treeish) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.branchMissing', "Select a branch to check out.")); + } + + const workingDirectory = URI.parse(workingDirectoryValue); + if (treeish.startsWith('-') || !await this._gitService.branchExists(workingDirectory, treeish)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.branchInvalid', "Branch '{0}' is not an existing local branch.", treeish)); + } + this._throwIfCancelled(token); + if (await this._gitService.hasUncommittedChanges(workingDirectory)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.dirty', "Commit or stash the current changes before checking out '{0}'.", treeish)); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostCheckoutOperationHandler] Checking out ${treeish} for session ${sessionUri}`); + try { + await this._gitService.checkout(workingDirectory, treeish); + } catch (error) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.failed', "Failed to check out '{0}': {1}", treeish, error instanceof Error ? error.message : String(error))); + } + + try { + await this._onCheckedOut(sessionUri); + } catch (error) { + this._logService.warn(`[AgentHostCheckoutOperationHandler] Post-checkout refresh failed for session ${sessionUri}: ${error instanceof Error ? error.message : String(error)}`); + } + + return { message: { markdown: localize('agentHost.changeset.checkout.checkedOut', "Checked out branch `{0}`.", treeish) } }; + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.cancelled', "Checkout operation was cancelled.")); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts new file mode 100644 index 00000000000000..748104f6b3706d --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetKind } from '../common/changesetUri.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, SessionLifecycle, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostCheckoutOperationHandler } from './agentHostCheckoutOperationHandler.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; + +export class AgentHostCheckoutOperationContribution extends Disposable implements IChangesetOperationContribution { + + private _registry: IChangesetOperationRegistry | undefined; + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + this._registry = registry; + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const handler = this._instantiationService.createInstance(AgentHostCheckoutOperationHandler, getSessionState, (sessionKey: string) => this._onCheckedOut(sessionKey)); + store.add(registry.registerChangesetOperationHandler(AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, handler)); + store.add({ dispose: () => { this._registry = undefined; } }); + return store; + } + + getOperations({ sessionKey, changesetKind }: IChangesetOperationContext): ChangesetOperation[] | undefined { + const state = this._stateManager.getSessionState(sessionKey); + if ( + state?.lifecycle !== SessionLifecycle.Creating || + this._stateManager.isUnusedDraft(sessionKey) !== true || + changesetKind !== ChangesetKind.Uncommitted + ) { + return undefined; + } + + return [{ + id: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + label: localize('agentHost.changeset.checkout', "Checkout"), + group: 'checkout', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + } satisfies ChangesetOperation]; + } + + private _onCheckedOut(sessionKey: string): void { + void this._registry?.refreshSessionGitState(sessionKey); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostContributions.ts b/src/vs/platform/agentHost/node/agentHostContributions.ts index 03ec7a8b76d58d..ac65d83300d900 100644 --- a/src/vs/platform/agentHost/node/agentHostContributions.ts +++ b/src/vs/platform/agentHost/node/agentHostContributions.ts @@ -7,6 +7,7 @@ import { DisposableStore } from '../../../base/common/lifecycle.js'; import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { AgentHostCheckoutOperationContribution } from './agentHostCheckoutOperationProvider.js'; import { IAgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -31,6 +32,7 @@ export function activateAgentHostContributions(accessor: ServicesAccessor, insta store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCheckoutOperationContribution))); const completions = accessor.get(IAgentHostCompletions); const stateManager = accessor.get(IAgentHostStateManager); diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index d143d376235390..cb8c9ea3e49ad3 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -367,6 +367,10 @@ export class AgentHostGitService implements IAgentHostGitService { await this._runGit(workingDirectory, args, { throwOnError: true }); } + async checkout(workingDirectory: URI, treeish: string): Promise { + await this._runGit(workingDirectory, ['checkout', '-q', treeish], { throwOnError: true }); + } + async hasUncommittedChanges(workingDirectory: URI): Promise { const output = await this._runGitStatus(workingDirectory, ['--porcelain']); return !!output && output.trim().length > 0; diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index d630fe630746d5..06dc2063b263c3 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -723,8 +723,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI sessionMutable: false, }); - // Resolve isolation first — downstream schema shapes (branch's - // read-only mode + enum restriction) depend on the effective value. + // Resolve isolation first because the branch default depends on the effective value. const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; const isolationValue = isolationProperty.validate(request.config?.[SessionConfigKey.Isolation]) ? request.config![SessionConfigKey.Isolation] as 'folder' | 'worktree' @@ -738,9 +737,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let worktreeBranchTrackProperty: ISchemaProperty | undefined; let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { - const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch; - branchValue = isolationValue === 'worktree' && typeof request.config?.[SessionConfigKey.Branch] === 'string' + branchValue = typeof request.config?.[SessionConfigKey.Branch] === 'string' ? request.config[SessionConfigKey.Branch] as string : branchDefault; branchProperty = schemaProperty({ @@ -750,8 +748,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI enum: [branchDefault], enumLabels: [branchDefault], default: branchDefault, - enumDynamic: !branchReadOnly, - readOnly: branchReadOnly, + enumDynamic: true, + readOnly: false, sessionMutable: false, }); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 5e043dc1af549a..e6c96253bfb5ee 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -351,6 +351,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index c84f0de91db0fc..b6e5e17b2e3983 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -454,6 +455,49 @@ suite('AgentHostChangesetOperationService', () => { }); }); + test('serializes in-flight invocations with different metadata', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const sessionKey = 'agent:/session'; + const changesetUri = buildUncommittedChangesetUri(sessionKey); + stateManager.registerChangeset(changesetUri); + stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetOperationsChanged, + operations: [{ id: testOperationId, label: 'Checkout', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle }], + }); + + const pending: DeferredPromise[] = []; + const metadata: (Record | undefined)[] = []; + const handler: IChangesetOperationHandler = { + invoke: params => { + metadata.push(params._meta); + const result = new DeferredPromise(); + pending.push(result); + return result.p; + }, + }; + const service = createService(stateManager); + disposables.add(service.registerContribution(new TestContribution(handler))); + + const first = service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId, _meta: { treeish: 'main' } }); + const second = service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId, _meta: { treeish: 'featureA' } }); + const metadataWhileFirstRunning = [...metadata]; + pending[0].complete({}); + await first; + const metadataAfterFirstCompleted = [...metadata]; + pending[1].complete({}); + await second; + + assert.deepStrictEqual({ + metadataWhileFirstRunning, + metadataAfterFirstCompleted, + metadata, + }, { + metadataWhileFirstRunning: [{ treeish: 'main' }], + metadataAfterFirstCompleted: [{ treeish: 'main' }, { treeish: 'featureA' }], + metadata: [{ treeish: 'main' }, { treeish: 'featureA' }], + }); + }); + test('publishes running and idle state around a successful changeset operation', async () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; diff --git a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts new file mode 100644 index 00000000000000..2f288fcea81294 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { checkoutOperationMeta } from '../../common/meta/agentCheckoutOperationMeta.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostCheckoutOperationHandler } from '../../node/agentHostCheckoutOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +suite('AgentHostCheckoutOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('checks out the selected branch from a clean working directory', async () => { + const session = URI.parse('agent:/session'); + const workingDirectory = URI.file('/repo'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: [workingDirectory.toString()], + }); + const gitCalls: string[] = []; + const gitService = new class extends mock() { + declare readonly _serviceBrand: undefined; + + override async hasUncommittedChanges(resource: URI): Promise { + gitCalls.push(`hasUncommittedChanges:${resource.toString()}`); + return false; + } + + override async branchExists(resource: URI, branchName: string): Promise { + gitCalls.push(`branchExists:${resource.toString()}:${branchName}`); + return branchName === 'dev'; + } + + override async checkout(resource: URI, treeish: string): Promise { + gitCalls.push(`checkout:${resource.toString()}:${treeish}`); + } + }(); + const refreshedSessions: string[] = []; + const handler = new AgentHostCheckoutOperationHandler( + sessionKey => stateManager.getSessionState(sessionKey), + async sessionKey => { refreshedSessions.push(sessionKey); }, + gitService, + new NullLogService(), + ); + + const result = await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('dev'), + }, CancellationToken.None); + let optionError: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('-Bmain'), + }, CancellationToken.None); + } catch (error) { + optionError = error as ProtocolError; + } + let missingBranchError: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('missing'), + }, CancellationToken.None); + } catch (error) { + missingBranchError = error as ProtocolError; + } + + assert.deepStrictEqual({ + gitCalls, + refreshedSessions, + message: result.message, + optionErrorCode: optionError?.code, + missingBranchErrorCode: missingBranchError?.code, + }, { + gitCalls: [ + `branchExists:${workingDirectory.toString()}:dev`, + `hasUncommittedChanges:${workingDirectory.toString()}`, + `checkout:${workingDirectory.toString()}:dev`, + `branchExists:${workingDirectory.toString()}:missing`, + ], + refreshedSessions: [session.toString()], + message: { markdown: 'Checked out branch `dev`.' }, + optionErrorCode: JsonRpcErrorCodes.InvalidParams, + missingBranchErrorCode: JsonRpcErrorCodes.InvalidParams, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts new file mode 100644 index 00000000000000..f5d09e20346f89 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { buildBranchChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../common/changesetUri.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostCheckoutOperationContribution } from '../../node/agentHostCheckoutOperationProvider.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +const sessionKey = 'agent:/session'; +const uncommittedChangesetUri = buildUncommittedChangesetUri(sessionKey); +const branchChangesetUri = buildBranchChangesetUri(sessionKey); + +suite('AgentHostCheckoutOperationContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(): { contribution: AgentHostCheckoutOperationContribution; stateManager: AgentHostStateManager } { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: sessionKey, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }); + const contribution = disposables.add(new AgentHostCheckoutOperationContribution( + stateManager, + disposables.add(new InstantiationService()), + )); + return { contribution, stateManager }; + } + + test('advertises Checkout only for the uncommitted changeset on a new session', () => { + const { contribution, stateManager } = createContribution(); + + const checkoutOperation = contribution.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + })?.[0]; + const branchOperations = contribution.getOperations({ + sessionKey, + changesetUri: branchChangesetUri, + changesetKind: ChangesetKind.Branch, + }); + + stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); + const readySessionOperations = contribution.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + }); + + assert.deepStrictEqual({ checkoutOperation, branchOperations, readySessionOperations }, { + checkoutOperation: { + id: 'checkout', + label: 'Checkout', + group: 'checkout', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + branchOperations: undefined, + readySessionOperations: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 3e2af826d5de99..7f48a5a9655792 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -48,6 +48,7 @@ class TestGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 3c68788aa2f0c7..604faab4b7c31b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -36,6 +36,7 @@ class TestGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { return true; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 2ce0736749515e..86693e21fa5902 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -600,6 +600,15 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { }); }); + (hasGit ? test : test.skip)('checkout switches a clean working directory to an existing branch', async () => { + const dir = initRepo(); + cp.execFileSync('git', ['branch', 'dev'], { cwd: dir, env, stdio: 'pipe' }); + + await svc!.checkout(URI.file(dir), 'dev'); + + assert.strictEqual(cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim(), 'dev'); + }); + (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { const dir = initRepo(); assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 6f800c856eefd7..44ae250540c6b9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -98,6 +98,7 @@ class TestGitService implements IAgentHostGitService { this.calls.push(`createBranch:${branchName}`); this.createdBranch = branchName; } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ff886d015d829d..f439c8c5ad007e 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1168,7 +1168,7 @@ suite('AgentService (node dispatcher)', () => { providerSetting: 'initial', }, selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, - folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' }, + folder: { isolation: 'folder', branch: 'feature/config', providerSetting: 'folder' }, }); }); @@ -6652,6 +6652,7 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -6760,6 +6761,7 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -14622,7 +14624,7 @@ suite('AgentService (node dispatcher)', () => { gitStateCalls: [{ resource: sourceDir.toString(), baseBranch: undefined }], diffCalls: [sourceDir.toString()], uncommittedFiles: [sourceFile], - uncommittedOperations: ['commit', 'discard-changes'], + uncommittedOperations: ['checkout', 'commit', 'discard-changes'], }, afterMaterialization: { workingDirectory: worktreeDir.toString(), diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5e5881e518b8d4..37b5e0c5720111 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -290,6 +290,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async createBranch(_workingDirectory: URI, branchName: string): Promise { this.existingBranches.add(branchName); } + async checkout(): Promise { } async hasUncommittedChanges(workingDirectory: URI): Promise { return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 36037d10ed78c1..c91f53e04aadbb 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -32,6 +32,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { return false; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 5a40399edfd6e1..f091cf29180679 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -195,20 +195,23 @@ suite('WorktreeIsolation', () => { const repoWorktree = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); const repoWorktreeSelected = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature' } }); const repoFolder = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }); + const repoFolderSelected = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.Branch]: 'main' } }); headCommit = undefined; // unborn HEAD (no commits) const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); assert.deepStrictEqual({ noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, - repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, + repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchDynamic: repoWorktree.branchProperty?.protocol.enumDynamic, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum }, - repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchDynamic: repoFolder.branchProperty?.protocol.enumDynamic, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + repoFolderSelected: { branchDefault: repoFolderSelected.branchDefault, branchValue: repoFolderSelected.branchValue }, noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, }, { noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, - repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, + repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchDynamic: true, branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] }, - repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + repoFolder: { value: 'folder', branchDefault: 'feature', branchDynamic: true, branchReadOnly: false, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + repoFolderSelected: { branchDefault: 'feature', branchValue: 'main' }, noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, }); }); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 5a47d165a3349c..234ef7dce0b486 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -186,6 +186,8 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { isSessionConfigResolving(sessionId: string): IObservable; /** Sets one dynamic configuration property and re-resolves the schema. */ setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; + /** Tracks a draft configuration side effect that must finish before the first request. */ + trackSessionConfigOperation(sessionId: string, operation: Promise): void; /** * Replaces the full set of running-session config values atomically. * diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index 0c2234bb598f1f..9cf6218dfbf1fd 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -23,7 +23,7 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionHasOpenPullRequestContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; @@ -410,6 +410,7 @@ export class NewSessionUncommittedChangesetOperationsActionContribution extends ?.find(candidate => candidate.id === UNCOMMITTED_CHANGES_CHANGESET_ID && candidate.isEnabled.read(reader)); const operations = changeset?.operations.read(reader) .filter(operation => operation.id !== AGENT_HOST_SYNC_CHANGESET_OPERATION_ID) + .filter(operation => operation.id !== AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID) .filter(operation => operation.scopes.includes(SessionChangesetOperationScope.Changeset)) ?? []; const hasUncommittedChanges = (activeSession.workspace.read(reader)?.folders[0]?.gitRepository?.uncommittedChanges ?? 0) > 0; diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 3ed3018713496e..941fde829ba18b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -11,7 +11,7 @@ import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { AGENT_HOST_MERGE_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_MERGE_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -206,7 +206,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic this.activeSessionChangesetOperationsObs = derived(reader => { const changeset = this.activeSessionChangesetObs.read(reader); - const operations = changeset?.operations.read(reader) ?? []; + const operations = (changeset?.operations.read(reader) ?? []) + .filter(operation => operation.id !== AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID); return activeSessionBaseBranchProtected.read(reader) ? operations.filter(operation => operation.id !== AGENT_HOST_MERGE_CHANGESET_OPERATION_ID) : operations; diff --git a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index 15e082d42ffb51..d0e49caa17d2fd 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; -import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -39,6 +39,11 @@ suite('Changes Actions', () => { label: 'Discard File', scopes: [SessionChangesetOperationScope.Resource], status: SessionChangesetOperationStatus.Idle, + }, { + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, }, { id: AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, label: 'Sync Changes', @@ -112,6 +117,7 @@ suite('Changes Actions', () => { visibleForChangesTab, visibleForTextTab, resourceOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}discard-file`) !== undefined, + checkoutOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID}`) !== undefined, syncOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_SYNC_CHANGESET_OPERATION_ID}`) !== undefined, }, { actions: [{ @@ -128,6 +134,7 @@ suite('Changes Actions', () => { visibleForChangesTab: true, visibleForTextTab: false, resourceOperationRegistered: false, + checkoutOperationRegistered: false, syncOperationRegistered: false, }); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index 926bfc3f331519..1ecd1750ea8e93 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -10,6 +10,7 @@ import { constObservable, observableValue } from '../../../../../base/common/obs import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -316,6 +317,26 @@ suite('ChangesViewService', () => { }); }); + test('hides checkout from generic changeset operations', () => { + const changeset = createChangeset([ + { + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, + { + id: 'create-pr', + label: 'Create PR', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, + ]); + const { service } = createHarness(createSession('draft', { changesets: [changeset] })); + + assert.deepStrictEqual(service.activeSessionChangesetOperationsObs.get().map(operation => operation.id), ['create-pr']); + }); + test('hides the Agent Host merge operation when the base branch is protected', () => { const operations: readonly ISessionChangesetOperation[] = [ { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index fd38a742ac6634..73f4ab29f702d0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -354,15 +354,15 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { return changes; } - async invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget): Promise { + async invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { const connection = this._options.getConnection(); if (!connection) { - return; + throw new Error(`Cannot invoke changeset operation '${operationId}' because the agent host connection is unavailable.`); } const channel = this.channelUriObs.get(); if (!channel) { - return; + throw new Error(`Cannot invoke changeset operation '${operationId}' because the changeset channel is unavailable.`); } const operation = this.operations.get().find(o => o.id === operationId); @@ -393,6 +393,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { resource: target.resource.toString() } : undefined, + _meta }); } finally { this._setOperationLocallyRunning(operationId, false); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index cbd6795106c460..1ce1e12b80d852 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -12,7 +12,7 @@ import { IActionWidgetService } from '../../../../../platform/actionWidget/brows import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { toAction } from '../../../../../base/common/actions.js'; -import { Delayer } from '../../../../../base/common/async.js'; +import { Delayer, SequencerByKey } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -54,17 +54,20 @@ import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_ import { PermissionPicker } from '../../copilotChatSessions/browser/permissionPicker.js'; import { MobilePermissionPicker } from '../../copilotChatSessions/browser/mobilePermissionPicker.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; -import { showMobilePickerSheet, IMobilePickerSheetItem, IMobilePickerSheetSearchSource } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; +import { showMobilePickerSheet, IMobilePickerSheetItem, IMobilePickerSheetSearchSource, MOBILE_PICKER_SHEET_CONFIRM } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; import { AgentHostModePicker } from './agentHostModePicker.js'; import { MobileAgentHostModePicker } from './mobile/mobileAgentHostModePicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownClaudePermissionModeSchema, isWellKnownCodexApprovalsSchema, isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { checkoutOperationMeta } from '../../../../../platform/agentHost/common/meta/agentCheckoutOperationMeta.js'; import { AgentHostClaudePermissionModePicker } from './agentHostClaudePermissionModePicker.js'; import { ClaudeSessionConfigKey } from '../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; import { AgentHostCodexApprovalsPicker } from './agentHostCodexApprovalsPicker.js'; import { isAutoApproveValuePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { CodexSessionConfigKey } from '../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; +import { type ISessionChangeset, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(SessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(SessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); @@ -346,6 +349,7 @@ export class AgentHostSessionConfigPicker extends Disposable { private readonly _devContainerCheckbox = this._register(new MutableDisposable()); private readonly _isolationCheckbox = this._register(new MutableDisposable()); protected readonly _filterDelayer = this._register(new Delayer[]>(200)); + private readonly _repositoryConfigSequencer = new SequencerByKey(); private _container: HTMLElement | undefined; /** @@ -386,7 +390,10 @@ export class AgentHostSessionConfigPicker extends Disposable { super(); this._register(autorun(reader => { - this._session.read(reader); + const session = this._session.read(reader); + for (const changeset of session?.changesets?.read(reader) ?? []) { + changeset.operations?.read(reader); + } this._renderConfigPickers(); })); @@ -521,7 +528,8 @@ export class AgentHostSessionConfigPicker extends Disposable { continue; } const value = resolvedConfig.values[property] ?? schema.default; - const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession); + const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession) + || (this._requiresBranchCheckout(provider, session.sessionId, property) && !this._getCheckoutChangeset(session.sessionId)); // Isolation renders as a Worktree checkbox on desktop; the phone layout keeps the chip for the unified repo sheet. if (property === SessionConfigKey.Isolation && this._shouldRenderIsolationAsCheckbox(schema)) { this._renderIsolationCheckbox(provider, session.sessionId, schema, value, isReadOnly, !isReadOnly && isLoading); @@ -625,6 +633,78 @@ export class AgentHostSessionConfigPicker extends Disposable { return !!schema.readOnly; } + protected async _setSessionConfigValue(provider: IAgentHostSessionsProvider, sessionId: string, property: string, value: unknown): Promise { + const isDraftRepositoryProperty = provider.getCreateSessionConfig(sessionId) !== undefined + && (property === SessionConfigKey.Isolation || property === SessionConfigKey.Branch); + if (!isDraftRepositoryProperty) { + await provider.setSessionConfigValue(sessionId, property, value); + return; + } + + let treeish: string | undefined; + if (property === SessionConfigKey.Branch) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('Branch configuration values must be non-empty strings.'); + } + treeish = value; + } + + const configOperation = this._repositoryConfigSequencer.queue(sessionId, async () => { + const shouldCheckout = this._requiresBranchCheckout(provider, sessionId, property); + if (!shouldCheckout) { + await provider.setSessionConfigValue(sessionId, property, value); + return; + } + if (!treeish) { + throw new Error('Branch checkout requires a non-empty treeish.'); + } + + const changeset = this._getCheckoutChangeset(sessionId); + if (!changeset) { + throw new Error('Branch checkout is not available for this session.'); + } + + const confirmedValue = provider.getSessionConfig(sessionId)?.values[property]; + try { + await provider.setSessionConfigValue(sessionId, property, value); + await changeset.invokeOperation( + AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + undefined, + checkoutOperationMeta(treeish), + ); + } catch (checkoutError) { + if (provider.getSessionConfig(sessionId)?.values[property] === value) { + try { + await provider.setSessionConfigValue(sessionId, property, confirmedValue); + } catch (rollbackError) { + throw new AggregateError([checkoutError, rollbackError], 'Checkout failed and the branch configuration could not be restored.'); + } + } + throw checkoutError; + } + }); + provider.trackSessionConfigOperation(sessionId, configOperation); + await configOperation; + } + + protected _requiresBranchCheckout(provider: IAgentHostSessionsProvider, sessionId: string, property: string): boolean { + return property === SessionConfigKey.Branch + && provider.getCreateSessionConfig(sessionId) !== undefined + && provider.getSessionConfig(sessionId)?.values[SessionConfigKey.Isolation] === 'folder'; + } + + protected _getCheckoutChangeset(sessionId: string): ISessionChangeset | undefined { + const session = this._session.get(); + if (session?.sessionId !== sessionId) { + return undefined; + } + + return session.changesets.get()?.find(changeset => + changeset.id === UNCOMMITTED_CHANGES_CHANGESET_ID + && changeset.operations.get().some(operation => operation.id === AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID) + ); + } + protected _renderTrigger(trigger: HTMLElement, sessionId: string, property: string, schema: SessionConfigPropertySchema, value: unknown | undefined, isReadOnly: boolean): void { dom.clearNode(trigger); @@ -802,7 +882,7 @@ export class AgentHostSessionConfigPicker extends Disposable { optionLabelAfter: this._getLabel(sessionId, SessionConfigKey.Isolation, schema, nextValue), isPII: false, }); - provider.setSessionConfigValue(sessionId, SessionConfigKey.Isolation, nextValue).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, SessionConfigKey.Isolation, nextValue).catch(() => { /* best-effort */ }); } protected async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, trigger: HTMLElement): Promise { @@ -854,7 +934,7 @@ export class AgentHostSessionConfigPicker extends Disposable { } const nextValue = schema.type === 'boolean' ? item.value === 'true' : item.value; - provider.setSessionConfigValue(sessionId, property, nextValue).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, property, nextValue).catch(() => { /* best-effort */ }); }, onFilter: schema.enumDynamic ? query => this._filterDelayer.trigger(async () => { @@ -1106,12 +1186,13 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { const isolationSchema = config.schema.properties[SessionConfigKey.Isolation]; const branchSchema = config.schema.properties[SessionConfigKey.Branch]; + const canSelectBranch = !this._requiresBranchCheckout(provider, sessionId, SessionConfigKey.Branch) || !!this._getCheckoutChangeset(sessionId); const [isolationItems, branchItems] = await Promise.all([ isolationSchema && !isolationSchema.readOnly ? this._getItems(provider, sessionId, SessionConfigKey.Isolation, isolationSchema) : Promise.resolve([] as readonly IConfigPickerItem[]), - branchSchema && !branchSchema.readOnly + branchSchema && !branchSchema.readOnly && canSelectBranch ? this._getItems(provider, sessionId, SessionConfigKey.Branch, branchSchema) : Promise.resolve([] as readonly IConfigPickerItem[]), ]); @@ -1162,7 +1243,7 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { } let search: IMobilePickerSheetSearchSource | undefined; - if (branchSchema?.enumDynamic && !branchSchema.readOnly) { + if (branchSchema?.enumDynamic && !branchSchema.readOnly && canSelectBranch) { search = { placeholder: localize('mobileAgentHostSessionConfig.repoSheet.branchSearchPlaceholder', "Search branches"), ariaLabel: localize('mobileAgentHostSessionConfig.repoSheet.branchSearchAria', "Search base branches"), @@ -1199,9 +1280,8 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { sheetItems, { search, - // Keep the sheet open on row taps so the user can adjust - // both isolation mode and branch without reopening. Each - // tap writes through immediately; Done just dismisses. + // Branch picks stay open for further branch searches. Isolation + // changes close the sheet so its branch rows cannot become stale. stayOpenOnSelect: true, onDidSelect: (id) => { const selection = idToConfig.get(id); @@ -1216,8 +1296,12 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { optionLabelAfter: selection.label, isPII: selection.isPII, }); - provider.setSessionConfigValue(sessionId, selection.property, selection.value).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, selection.property, selection.value).catch(() => { /* best-effort */ }); + if (selection.property === SessionConfigKey.Isolation) { + return MOBILE_PICKER_SHEET_CONFIRM; + } } + return undefined; }, }, ); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 49f058d851e42f..4015523a2a47a4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -2009,6 +2009,7 @@ class NewSession extends Disposable { */ private _config: ResolveSessionConfigResult | undefined = { schema: { type: 'object', properties: {} }, values: {} }; private _configResolution: Promise | undefined; + private _configOperation: Promise | undefined; /** * Monotonic counter for in-flight {@link resolveConfig} calls. Each call @@ -2278,23 +2279,54 @@ class NewSession extends Disposable { } } + trackConfigOperation(operation: Promise): void { + this._configOperation = operation; + void operation.then( + () => this._clearConfigOperation(operation), + () => this._clearConfigOperation(operation), + ); + } + + async waitForConfigurationReady(): Promise { + while (this._configOperation || this._configResolution) { + if (this._configOperation) { + await raceCancellationError(this._configOperation, this.cancellationToken); + } else { + await this.waitForConfigResolution(); + } + } + } + private _clearConfigResolution(promise: Promise): void { if (this._configResolution === promise) { this._configResolution = undefined; } } + private _clearConfigOperation(promise: Promise): void { + if (this._configOperation === promise) { + this._configOperation = undefined; + } + } + /** - * Optimistically merges a single property into the cached config. + * Optimistically updates a single property in the cached config. + * An undefined value removes the property. * Preserves the existing schema so schema-driven pickers don't flash * during the async re-resolve. {@link resolveConfig} replaces both * schema and values when its response lands. */ setConfigValue(property: string, value: unknown): void { const current = this._config; + const values = { ...(current?.values ?? {}) }; + if (value === undefined) { + delete values[property]; + } else { + values[property] = value; + } this._config = { schema: current?.schema ?? { type: 'object', properties: {} }, - values: { ...(current?.values ?? {}), [property]: value }, + values, }; this._syncWorktreePending(); } @@ -3797,13 +3829,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Mark resolution before firing so the first picker render is already inert. const newSession = this._getNewSession(sessionId); if (newSession) { - // Defense-in-depth: pickers render disabled during a resolve, - // but keyboard dropdown and mobile sheet paths bypass that. - // Drop the second pick so it can't race the schema replacement. - if (newSession.isResolvingConfig.get()) { - return; + while (newSession.isResolvingConfig.get()) { + await newSession.waitForConfigResolution(); + if (this._getNewSession(sessionId) !== newSession) { + return; + } } newSession.beginResolveConfigSync(); + if (property === SessionConfigKey.Isolation) { + newSession.setConfigValue(SessionConfigKey.Branch, undefined); + } newSession.setConfigValue(property, normalizedValue); this._onDidChangeSessionConfig.fire(sessionId); await newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession)); @@ -3816,6 +3851,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!runningConfig || !connection) { return; } + const schema = runningConfig.schema.properties[property]; if (!schema?.sessionMutable) { return; @@ -3840,6 +3876,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + trackSessionConfigOperation(sessionId: string, operation: Promise): void { + this._getNewSession(sessionId)?.trackConfigOperation(operation); + } + async replaceSessionConfig(sessionId: string, values: Record): Promise { const runningConfig = this._runningSessionConfigs.get(sessionId); const connection = this.connection; @@ -3996,7 +4036,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement mode === 'workspace' ? 'folder' : mode, policyRestricted, ); - await this._setTransientNewSessionConfigValue(sessionId, SessionConfigKey.Isolation, value); + await this._setTransientNewSessionConfigValues(sessionId, { [SessionConfigKey.Isolation]: value }, true, [SessionConfigKey.Branch]); } async setWorktreeConfiguration(sessionId: string, configuration: ISessionWorktreeConfiguration): Promise { @@ -4018,7 +4058,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (configuration.branch) { values[SessionConfigKey.Branch] = normalizeSessionConfigValue(SessionConfigKey.Branch, configuration.branch, policyRestricted); } - await this._setTransientNewSessionConfigValues(sessionId, values, false); + const unsetProperties = configuration.isolationMode && !configuration.branch ? [SessionConfigKey.Branch] : undefined; + await this._setTransientNewSessionConfigValues(sessionId, values, false, unsetProperties); } async setWorktreeBranchTrack(sessionId: string, enabled: boolean): Promise { @@ -4039,7 +4080,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement await this._setTransientNewSessionConfigValues(sessionId, { [property]: value }, true); } - private async _setTransientNewSessionConfigValues(sessionId: string, values: Readonly>, waitForCurrentResolve: boolean): Promise { + private async _setTransientNewSessionConfigValues(sessionId: string, values: Readonly>, waitForCurrentResolve: boolean, unsetProperties?: readonly string[]): Promise { const newSession = this._getNewSession(sessionId); if (!newSession) { throw new Error('Cannot configure repository settings after session creation.'); @@ -4053,6 +4094,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } newSession.beginResolveConfigSync(); + for (const property of unsetProperties ?? []) { + newSession.setConfigValue(property, undefined); + } for (const [property, value] of Object.entries(values)) { newSession.setConfigValue(property, value); } @@ -4878,7 +4922,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!this.connection) { throw new Error(this._notConnectedSendErrorMessage()); } - await newSession.waitForConfigResolution(); + await newSession.waitForConfigurationReady(); await newSession.waitForEagerCreate(); if (this._getNewSession(newSession.sessionId) !== newSession) { throw new Error('Session was disposed before its configuration could be applied.'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 07dd66f93b2232..282739eb26a00c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; @@ -14,6 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionConfigKey } from '../../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ResolveSessionConfigResult, SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; @@ -35,7 +37,7 @@ import { ISessionChangesService } from '../../../../../../contrib/changes/browse import { CHANGES_VIEW_ID } from '../../../../../../contrib/changes/common/changes.js'; import { ISessionsProvidersService } from '../../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionWorkspace } from '../../../../../../services/sessions/common/session.js'; +import { ISessionChangeset, ISessionChangesetOperationTarget, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostSessionConfigPicker, IConfigPickerItem, PickerActionViewItem } from '../../../browser/agentHostSessionConfigPicker.js'; @@ -89,7 +91,7 @@ function makeRepoConfig(branchValue?: string, isolation: 'folder' | 'worktree' = } /** A config whose Branch property is resolved dynamically (no static `enum`), as the real branch picker is. */ -function makeDynamicBranchConfig(branchValue: string): ResolveSessionConfigResult { +function makeDynamicBranchConfig(branchValue: string, isolation: 'folder' | 'worktree' = 'worktree'): ResolveSessionConfigResult { return { schema: { type: 'object', @@ -105,7 +107,7 @@ function makeDynamicBranchConfig(branchValue: string): ResolveSessionConfigResul }, }, }, - values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: branchValue }, + values: { [SessionConfigKey.Isolation]: isolation, [SessionConfigKey.Branch]: branchValue }, } as ResolveSessionConfigResult; } @@ -130,26 +132,40 @@ function makeNoGitConfig(): ResolveSessionConfigResult { * provider (not the picker) owns the seeded schema, so a picker recreated by a * toolbar rebuild still reads the seeded chips from here. */ -class FakeProvider implements Pick { +class FakeProvider implements Pick { readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; readonly onDidChangeSessionConfig: Event; config: ResolveSessionConfigResult = makeRepoConfig('main'); readonly resolving = observableValue('resolving', false); isNew = true; setSessionConfigValueCalls = 0; + readonly setSessionConfigValueArguments: { sessionId: string; property: string; value: unknown }[] = []; devContainerEnabled = false; devContainerAvailable = true; /** Completions returned by `getSessionConfigCompletions`, e.g. for the dynamic branch picker. */ completions: readonly SessionConfigValueItem[] = []; - constructor(private readonly _emitter: Emitter) { + constructor( + private readonly _emitter: Emitter, + private readonly _onSetSessionConfigValue?: (value: unknown) => void, + ) { this.onDidChangeSessionConfig = _emitter.event; } getSessionConfig(): ResolveSessionConfigResult | undefined { return this.config; } getCreateSessionConfig(): Record | undefined { return this.isNew ? {} : undefined; } isSessionConfigResolving() { return this.resolving; } - async setSessionConfigValue(): Promise { this.setSessionConfigValueCalls++; } + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { + this.setSessionConfigValueCalls++; + this.setSessionConfigValueArguments.push({ sessionId, property, value }); + this._onSetSessionConfigValue?.(value); + this.config = { + ...this.config, + values: { ...this.config.values, [property]: value }, + }; + this._emitter.fire(sessionId); + } + trackSessionConfigOperation(_sessionId: string, _operation: Promise): void { } async getSessionConfigCompletions(): Promise { return this.completions; } isDevContainerAvailable(): boolean { return this.devContainerAvailable; } isDevContainerEnabled(): boolean { return this.devContainerEnabled; } @@ -171,6 +187,10 @@ class AlwaysRenderConfigPicker extends AgentHostSessionConfigPicker { return true; } + setSessionConfigValueForTest(provider: FakeProvider, property: string, value: unknown): Promise { + return this._setSessionConfigValue(provider as unknown as IAgentHostSessionsProvider, SESSION_ID, property, value); + } + renderTriggerForTest(trigger: HTMLElement, property: string, schema: SessionConfigPropertySchema, value: unknown, isReadOnly: boolean): void { this._renderTrigger(trigger, SESSION_ID, property, schema, value, isReadOnly); } @@ -209,9 +229,14 @@ class CapturingActionWidgetHolder { readonly events: string[] = []; } -function setupServices(store: Pick, 'add'>, options?: { devContainerWorktreeEnabled?: boolean }) { +function setupServices( + store: Pick, 'add'>, + options?: { devContainerWorktreeEnabled?: boolean }, + onCheckout?: () => Promise, +) { const emitter = store.add(new Emitter()); - const provider = new FakeProvider(emitter); + const branchSelectionEvents: string[] = []; + const provider = new FakeProvider(emitter, value => branchSelectionEvents.push(`set:${String(value)}`)); const actionWidget = new CapturingActionWidgetHolder(); const instantiationService = store.add(new TestInstantiationService()); @@ -264,13 +289,31 @@ function setupServices(store: Pick('workspace', makeWorkspace(undefined)); const workspace: IObservable = workspaceObs; - const sessionObs = observableValue('activeSession', { - providerId: LOCAL_AGENT_HOST_PROVIDER_ID, - sessionId: SESSION_ID, - resource: SESSION_RESOURCE, - workspace, - } as IActiveSession); - return { instantiationService, provider, sessionObs, workspaceObs, actionWidget }; + const checkoutInvocations: { operationId: string; _meta: Record | undefined }[] = []; + const uncommittedChangeset = new class extends mock() { + override readonly id = UNCOMMITTED_CHANGES_CHANGESET_ID; + override readonly operations = constObservable([{ + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }]); + override async invokeOperation(operationId: string, _target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { + checkoutInvocations.push({ operationId, _meta }); + branchSelectionEvents.push('checkout'); + await onCheckout?.(); + } + }(); + const changesetsObs = observableValue('changesets', [uncommittedChangeset]); + const activeSession = new class extends mock() { + override readonly providerId = LOCAL_AGENT_HOST_PROVIDER_ID; + override readonly sessionId = SESSION_ID; + override readonly resource = SESSION_RESOURCE; + override readonly workspace = workspace; + override readonly changesets = changesetsObs; + }(); + const sessionObs = observableValue('activeSession', activeSession); + return { instantiationService, provider, sessionObs, workspaceObs, changesetsObs, uncommittedChangeset, actionWidget, checkoutInvocations, branchSelectionEvents }; } /** Create and render a fresh picker instance, as the toolbar does on a rebuild. */ @@ -584,6 +627,258 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('folder sessions populate the branch picker and invoke Checkout when a branch is selected', async () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'dev', label: 'dev' }, + ]; + const { container } = renderPicker(store, services); + + const trigger = branchSlot(container)!.querySelector('a.action-label'); + trigger?.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate?.onSelect({ value: 'dev', label: 'dev' }); + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + hasInteractiveTrigger: !!trigger, + ariaReadOnly: trigger?.getAttribute('aria-readonly'), + items: services.actionWidget.items.filter(item => item.kind === ActionListItemKind.Action).map(item => item.label), + setConfigArguments: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + branchSelectionEvents: services.branchSelectionEvents, + }, { + hasInteractiveTrigger: true, + ariaReadOnly: null, + items: ['main', 'dev'], + setConfigArguments: [{ sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'dev' }], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'dev' }, + }], + branchSelectionEvents: ['set:dev', 'checkout'], + }); + }); + + test('folder branch picker stays read-only until Checkout capability hydrates', () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.changesetsObs.set(undefined, undefined); + const { container } = renderPicker(store, services); + + const before = branchSlot(container)?.querySelector('.action-label'); + services.changesetsObs.set([services.uncommittedChangeset], undefined); + const after = branchSlot(container)?.querySelector('.action-label'); + + assert.deepStrictEqual({ + before: { + tagName: before?.tagName, + ariaReadOnly: before?.getAttribute('aria-readonly'), + }, + after: { + tagName: after?.tagName, + role: after?.getAttribute('role'), + }, + }, { + before: { + tagName: 'SPAN', + ariaReadOnly: 'true', + }, + after: { + tagName: 'A', + role: 'button', + }, + }); + }); + + test('failed folder branch checkout restores the previous configuration value', async () => { + const services = setupServices(store, {}, async () => { + throw new Error('Checkout failed'); + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + ]; + const { container } = renderPicker(store, services); + + branchSlot(container)!.querySelector('a.action-label')!.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + branchSelectionEvents: services.branchSelectionEvents, + }, { + branch: 'main', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }], + branchSelectionEvents: ['set:featureA', 'checkout', 'set:main'], + }); + }); + + test('serializes repeated folder branch checkouts with their selected treeish', async () => { + const firstCheckoutStarted = new DeferredPromise(); + const releaseFirstCheckout = new DeferredPromise(); + const secondCheckoutStarted = new DeferredPromise(); + let checkoutCount = 0; + const services = setupServices(store, {}, async () => { + checkoutCount++; + if (checkoutCount === 1) { + firstCheckoutStarted.complete(); + await releaseFirstCheckout.p; + } else { + secondCheckoutStarted.complete(); + } + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + ]; + const { container } = renderPicker(store, services); + const trigger = branchSlot(container)!.querySelector('a.action-label')!; + trigger.click(); + await new Promise(resolve => setTimeout(resolve)); + + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await firstCheckoutStarted.p; + services.actionWidget.delegate?.onSelect({ value: 'main', label: 'main' }); + await new Promise(resolve => setTimeout(resolve)); + assert.deepStrictEqual({ + label: branchLabel(container), + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + label: 'featureA', + branch: 'featureA', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }], + }); + + releaseFirstCheckout.complete(); + await secondCheckoutStarted.p; + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }, { + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'main' }, + }], + }); + assert.deepStrictEqual(services.branchSelectionEvents, ['set:featureA', 'checkout', 'set:main', 'checkout']); + }); + + test('serializes interleaved branch and isolation selections before deciding checkout', async () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'worktree'); + const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); + + await Promise.all([ + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'featureA'), + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Isolation, 'folder'), + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'featureB'), + ]); + + assert.deepStrictEqual({ + values: services.provider.config.values, + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + values: { + [SessionConfigKey.Isolation]: 'folder', + [SessionConfigKey.Branch]: 'featureB', + }, + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Isolation, value: 'folder' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureB' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureB' }, + }], + }); + }); + + test('rolls back failed queued checkouts to the last checked-out branch', async () => { + const firstCheckoutStarted = new DeferredPromise(); + const releaseFirstCheckout = new DeferredPromise(); + const secondCheckoutStarted = new DeferredPromise(); + let checkoutCount = 0; + const services = setupServices(store, {}, async () => { + checkoutCount++; + if (checkoutCount === 1) { + firstCheckoutStarted.complete(); + await releaseFirstCheckout.p; + throw new Error('First Checkout failed'); + } + secondCheckoutStarted.complete(); + throw new Error('Second Checkout failed'); + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + { value: 'featureB', label: 'featureB' }, + ]; + const { container } = renderPicker(store, services); + branchSlot(container)!.querySelector('a.action-label')!.click(); + await new Promise(resolve => setTimeout(resolve)); + + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await firstCheckoutStarted.p; + services.actionWidget.delegate?.onSelect({ value: 'featureB', label: 'featureB' }); + await new Promise(resolve => setTimeout(resolve)); + assert.strictEqual(branchLabel(container), 'featureA'); + + releaseFirstCheckout.complete(); + await secondCheckoutStarted.p; + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + branchSelectionEvents: services.branchSelectionEvents, + }, { + branch: 'main', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureB' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + branchSelectionEvents: ['set:featureA', 'checkout', 'set:main', 'set:featureB', 'checkout', 'set:main'], + }); + }); + test('dirty branch action selects the Changes tab before focusing the Changes view', async () => { const services = setupServices(store); services.provider.config = makeDynamicBranchConfig('main'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index 84eda27e44c7ac..531aaf7a4d3b84 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -249,6 +249,26 @@ suite('AgentHostSessionChangesets', () => { selectDefault(['uncommitted'], ChangesetKind.Session), ['uncommitted*']); }); + + test('rejects operation invocation while disconnected', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => undefined, + agentCapabilities: constObservable(undefined), + mapBackendSessionResource: resource => resource, + }; + const [changeset] = createChangesets(sessionUri, options, constObservable(false), [entry(ChangesetKind.Uncommitted)]); + + await assert.rejects( + () => changeset.invokeOperation('checkout'), + /agent host connection is unavailable/, + ); + }); }); test('binds Agent Merge changes to completed repair turns after the last default-chat user turn', () => { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index cb033f3541df18..c8018bc4f46a29 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -3745,6 +3745,70 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('serializes draft config writes instead of dropping selections during resolution', async () => { + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'folder' }, + }; + const barrier = agentHost.resolveSessionConfigBarrier = new DeferredPromise(); + + const isolationUpdate = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'folder'); + const branchUpdate = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Branch, 'feature'); + await timeout(0); + const requestsWhileResolving = agentHost.resolveSessionConfigRequests.slice(-1).map(request => request.config); + + barrier.complete(); + await Promise.all([isolationUpdate, branchUpdate]); + + assert.deepStrictEqual({ + requestsWhileResolving, + finalRequests: agentHost.resolveSessionConfigRequests.slice(-2).map(request => request.config), + }, { + requestsWhileResolving: [{ isolation: 'folder' }], + finalRequests: [ + { isolation: 'folder' }, + { isolation: 'folder', branch: 'feature' }, + ], + }); + }); + + test('first send waits for tracked draft config operations', async () => { + let sendCalls = 0; + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + sendRequest: async (): Promise => { + sendCalls++; + agentHost.addSession(createSession('config-operation-send', { summary: 'Config Operation' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); + const chat = await provider.createNewChat(session.sessionId); + const barrier = new DeferredPromise(); + provider.trackSessionConfigOperation(session.sessionId, barrier.p); + + const send = provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + await timeout(0); + const pendingSendCalls = sendCalls; + + barrier.complete(); + const committed = await send; + + assert.deepStrictEqual({ + pendingSendCalls, + sendCalls, + title: committed.title.get(), + }, { + pendingSendCalls: 0, + sendCalls: 1, + title: 'Config Operation', + }); + }); + test('first send waits for trusted eager backend creation', async () => { const workspaceTrustBarrier = new DeferredPromise(); let sendCalls = 0; @@ -3860,14 +3924,18 @@ suite('LocalAgentHostSessionsProvider', () => { test('maps the existing isolation setter to agent-host config without remembering it', async () => { const storageService = disposables.add(new InMemoryStorageService()); + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'feature' }, + }; const provider = createProvider(disposables, agentHost, undefined, { storageService }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); - await timeout(0); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.branch === 'feature'); const firstAutomationRequest = agentHost.resolveSessionConfigRequests.length; agentHost.resolveSessionConfigResult = { schema: { type: 'object', properties: {} }, - values: { isolation: 'folder', branch: 'main' }, + values: { isolation: 'folder', branch: 'feature' }, }; await provider.setIsolationMode(session.sessionId, 'workspace'); @@ -3884,6 +3952,40 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('resets the branch to the isolation default when New Worktree is toggled', async () => { + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'main' }, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.branch === 'main'); + const firstToggleRequest = agentHost.resolveSessionConfigRequests.length; + + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'folder', branch: 'feature' }, + }; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'folder'); + + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'main' }, + }; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'worktree'); + + assert.deepStrictEqual({ + requests: agentHost.resolveSessionConfigRequests.slice(firstToggleRequest).map(request => request.config), + config: provider.getCreateSessionConfig(session.sessionId), + }, { + requests: [ + { isolation: 'folder' }, + { isolation: 'worktree' }, + ], + config: { isolation: 'worktree', branch: 'main' }, + }); + }); + test('maps the programmatic branch tracking setter to hidden agent-host config without remembering it', async () => { const storageService = disposables.add(new InMemoryStorageService()); const provider = createProvider(disposables, agentHost, undefined, { storageService }); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 150141410cd5fd..fdd399a20d4fd7 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -476,11 +476,10 @@ export interface ISessionChangeset { /** * Invoke an operation declared in {@link operations}. `target` must be - * provided for resource-scoped operations and omitted for changeset- - * scoped ones — implementations are expected to validate this against - * the corresponding {@link ISessionChangesetOperation.scopes}. + * provided for resource-scoped operations and omitted for changeset-scoped + * ones. `_meta` carries optional operation-specific request metadata. */ - invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget): Promise; + invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget, _meta?: Record): Promise; /** * Sets the review state for a list of resources when the changeset supports review.