From f9fcd8412f59d6b9fa0739cda43a28fd53f4d27a Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 20 Apr 2026 12:23:45 +0200 Subject: [PATCH 01/23] inline chat: add experiment-controlled reasoning effort and thinking settings --- extensions/copilot/package.json | 27 +++++++++++++++++++ extensions/copilot/package.nls.json | 2 ++ .../inlineChat/node/inlineChatIntent.ts | 12 +++++++++ .../common/configurationService.ts | 2 ++ .../platform/endpoint/node/responsesApi.ts | 3 ++- 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2f5d24135b8b5..6507d2fdbfdc5 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4231,6 +4231,33 @@ "onExp" ] }, + "github.copilot.chat.inlineChat.reasoningEffort": { + "type": "string", + "default": "low", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high" + ], + "markdownDescription": "%github.copilot.config.inlineChat.reasoningEffort%", + "tags": [ + "advanced", + "experimental", + "onExp" + ] + }, + "github.copilot.chat.inlineChat.enableThinking": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.inlineChat.enableThinking%", + "tags": [ + "advanced", + "experimental", + "onExp" + ] + }, "github.copilot.chat.debug.requestLogger.maxEntries": { "type": "number", "default": 100, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 48568dfeee76e..97e406c222774 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -374,6 +374,8 @@ "github.copilot.config.localWorkspaceRecording.enabled": "Enable local workspace recording for analysis.", "github.copilot.config.editRecording.enabled": "Enable edit recording for analysis.", "github.copilot.config.inlineChat.selectionRatioThreshold": "Threshold at which to switch editing strategies for inline chat. When a selection portion of code matches a parse tree node, only that is presented to the language model. This speeds up response times but might have lower quality results. Requires having a parse tree for the document and the `inlineChat.enableV2` setting. Values must be between 0 and 1, where 0 means off and 1 means the selection perfectly matches a parse tree node.", + "github.copilot.config.inlineChat.reasoningEffort": "Controls the reasoning effort level for inline chat requests. Lower values result in faster responses with fewer reasoning tokens. Supported values depend on the model.", + "github.copilot.config.inlineChat.enableThinking": "Controls whether thinking/reasoning is enabled for inline chat requests. When disabled, reasoning summaries are suppressed for faster responses.", "github.copilot.config.debug.requestLogger.maxEntries": "Maximum number of entries to keep in the request logger for debugging purposes.", "github.copilot.config.chat.agentDebugLog.enabled": "Deprecated: use `github.copilot.chat.agentDebugLog.fileLogging.enabled` instead.", "github.copilot.config.chat.agentDebugLog.enabled.deprecated": "This setting has been merged into `github.copilot.chat.agentDebugLog.fileLogging.enabled`. Please use this setting instead.", diff --git a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts index c55cc7143a354..05f1ef0ed0d4e 100644 --- a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts @@ -305,6 +305,8 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @IToolsService private readonly _toolsService: IToolsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IExperimentationService private readonly _experimentationService: IExperimentationService, ) { } async executeEdit(endpoint: IChatEndpoint, conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext, chatTelemetry: ChatTelemetryBuilder): Promise { @@ -455,6 +457,10 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { userInitiatedRequest: true, location: ChatLocation.Editor, requestOptions, + modelCapabilities: { + enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService), + reasoningEffort: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), + }, telemetryProperties: { messageId: telemetry.telemetryMessageId, conversationId: telemetry.sessionId, @@ -603,6 +609,8 @@ class InlineChatEditHeuristicStrategy implements IInlineChatEditStrategy { constructor( private readonly _intent: InlineChatIntent, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IExperimentationService private readonly _experimentationService: IExperimentationService, ) { } async executeEdit(endpoint: IChatEndpoint, conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext, chatTelemetry: ChatTelemetryBuilder): Promise { @@ -651,6 +659,10 @@ class InlineChatEditHeuristicStrategy implements IInlineChatEditStrategy { messages: renderResult.messages, userInitiatedRequest: true, location: ChatLocation.Editor, + modelCapabilities: { + enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService), + reasoningEffort: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), + }, telemetryProperties: { messageId: telemetry.telemetryMessageId, conversationId: telemetry.sessionId, diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index e0bf525b7ef24..916b882ef2c76 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -640,6 +640,8 @@ export namespace ConfigKey { export const UseAlternativeNESNotebookFormat = defineAndMigrateExpSetting('chat.advanced.notebook.alternativeNESFormat.enabled', 'chat.notebook.alternativeNESFormat.enabled', false); export const InlineChatSelectionRatioThreshold = defineSetting('chat.inlineChat.selectionRatioThreshold', ConfigType.ExperimentBased, 0); + export const InlineChatReasoningEffort = defineSetting('chat.inlineChat.reasoningEffort', ConfigType.ExperimentBased, 'low'); + export const InlineChatEnableThinking = defineSetting('chat.inlineChat.enableThinking', ConfigType.ExperimentBased, false); export const InstantApplyShortModelName = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextModelName', 'chat.instantApply.shortContextModelName', CHAT_MODEL.SHORT_INSTANT_APPLY); export const InstantApplyShortContextLimit = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextLimit', 'chat.instantApply.shortContextLimit', 8000); diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index c18488dcbe262..93729f900a9ff 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -86,8 +86,9 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; + const thinkingExplicitlyDisabled = options.modelCapabilities?.enableThinking === false; const summaryConfig = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiReasoningSummary, expService); - const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview'; + const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview' || thinkingExplicitlyDisabled; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') From 9de7ce0c41692c6c7ac221119b4336df7c794528 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 20 Apr 2026 12:29:16 +0200 Subject: [PATCH 02/23] inline chat: honor user modelConfiguration.reasoningEffort from UI --- .../src/extension/inlineChat/node/inlineChatIntent.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts index 05f1ef0ed0d4e..f077dba256401 100644 --- a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts @@ -459,7 +459,9 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { requestOptions, modelCapabilities: { enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService), - reasoningEffort: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), + reasoningEffort: typeof request.modelConfiguration?.reasoningEffort === 'string' + ? request.modelConfiguration.reasoningEffort + : this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), }, telemetryProperties: { messageId: telemetry.telemetryMessageId, @@ -661,7 +663,9 @@ class InlineChatEditHeuristicStrategy implements IInlineChatEditStrategy { location: ChatLocation.Editor, modelCapabilities: { enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService), - reasoningEffort: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), + reasoningEffort: typeof request.modelConfiguration?.reasoningEffort === 'string' + ? request.modelConfiguration.reasoningEffort + : this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService), }, telemetryProperties: { messageId: telemetry.telemetryMessageId, From 5635ce9a00d504c3f852f5f05b2bbdaf146451df Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:11:27 -0700 Subject: [PATCH 03/23] Defer EditorMouseEvent position computation The `editorPos` and `relativePos` properties cause re-layouts because they read the target's bounding client rect. Since very few listeners actually need this, I think we should try to defer these There's a chance this could cause issues if the element is moved during the handler itself but the same would happen if checking the target's dom node directly. Testing looks ok but let's see if there are any weird edge cases I missed --- src/vs/editor/browser/editorDom.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts index 5e41bb11c157d..4077e9cadc5a5 100644 --- a/src/vs/editor/browser/editorDom.ts +++ b/src/vs/editor/browser/editorDom.ts @@ -119,21 +119,30 @@ export class EditorMouseEvent extends StandardMouseEvent { /** * Editor's coordinates relative to the whole document. */ - public readonly editorPos: EditorPagePosition; + public get editorPos(): EditorPagePosition { + this._editorPos ??= createEditorPagePosition(this._editorViewDomNode); + return this._editorPos; + } + private _editorPos: EditorPagePosition | undefined; /** * Coordinates relative to the (top;left) of the editor. * *NOTE*: These coordinates are preferred because they take into account transformations applied to the editor. * *NOTE*: These coordinates could be negative if the mouse position is outside the editor. - */ - public readonly relativePos: CoordinatesRelativeToEditor; + */ + public get relativePos(): CoordinatesRelativeToEditor { + this._relativePos ??= createCoordinatesRelativeToEditor(this._editorViewDomNode, this.editorPos, this.pos); + return this._relativePos; + } + private _relativePos: CoordinatesRelativeToEditor | undefined; + + private readonly _editorViewDomNode: HTMLElement; constructor(e: MouseEvent, isFromPointerCapture: boolean, editorViewDomNode: HTMLElement) { super(dom.getWindow(editorViewDomNode), e); this.isFromPointerCapture = isFromPointerCapture; this.pos = new PageCoordinates(this.posx, this.posy); - this.editorPos = createEditorPagePosition(editorViewDomNode); - this.relativePos = createCoordinatesRelativeToEditor(editorViewDomNode, this.editorPos, this.pos); + this._editorViewDomNode = editorViewDomNode; } } From e186b4cc5d9c921af7599a5d2167f6551a045f09 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 21 Apr 2026 10:03:49 +0100 Subject: [PATCH 04/23] feat(chat): add randomized placeholders for new-session chat input --- .../contrib/chat/browser/newChatInput.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 33d9d5fb003d8..e11b38723653c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -59,6 +59,31 @@ interface IDraftState { attachments: readonly IChatRequestVariableEntry[]; } +/** + * Randomized, friendly placeholders shown in the new-session chat input + * to add a bit of personality. One is picked per widget instance. + */ +function getRandomChatInputPlaceholder(): string { + const placeholders = [ + localize('sessionsChatInput.placeholder.whatAreYouBuilding', "What are you building?"), + localize('sessionsChatInput.placeholder.whatWillYouShipToday', "What will you ship today?"), + localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build..."), + localize('sessionsChatInput.placeholder.whatsYourNextMilestone', "What's your next milestone?"), + localize('sessionsChatInput.placeholder.whatAreYouTryingToAchieve', "What are you trying to achieve?"), + localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea..."), + localize('sessionsChatInput.placeholder.whatsTheGoal', "What's the goal?"), + localize('sessionsChatInput.placeholder.whatWillYouCreate', "What will you create?"), + localize('sessionsChatInput.placeholder.whatFeatureAreYouDreamingUp', "What feature are you dreaming up?"), + localize('sessionsChatInput.placeholder.describeTheOutcome', "Describe the outcome you want..."), + localize('sessionsChatInput.placeholder.whatProblemAreYouSolving', "What problem are you solving?"), + localize('sessionsChatInput.placeholder.whatsNextOnYourRoadmap', "What's next on your roadmap?"), + localize('sessionsChatInput.placeholder.whatWouldYouLikeToAutomate', "What would you like to automate?"), + localize('sessionsChatInput.placeholder.whatWillYouLaunch', "What will you launch?"), + localize('sessionsChatInput.placeholder.describeYourMission', "Describe your mission..."), + ]; + return placeholders[Math.floor(Math.random() * placeholders.length)]; +} + // #region --- New Chat Widget --- export class NewChatInputWidget extends Disposable implements IHistoryNavigationWidget { @@ -232,7 +257,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation ...getSimpleEditorOptions(this.configurationService), readOnly: false, ariaLabel: this._getAriaLabel(), - placeholder: this.options.placeholder, + placeholder: this.options.placeholder ?? getRandomChatInputPlaceholder(), fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: 13, lineHeight: 20, From 0c740b2c73d559b8137a9f5f749b89292c978ff0 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 21 Apr 2026 10:07:19 +0100 Subject: [PATCH 05/23] feat(chat): enhance chat input with randomized placeholders to improve user experience Co-authored-by: Copilot --- .../contrib/chat/browser/newChatInput.ts | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index e11b38723653c..d8bad65a7745a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -61,27 +61,35 @@ interface IDraftState { /** * Randomized, friendly placeholders shown in the new-session chat input - * to add a bit of personality. One is picked per widget instance. + * to add a bit of personality. One is picked per widget instance, avoiding + * an immediate repeat of the previous pick. */ +const RANDOM_PLACEHOLDERS = [ + localize('sessionsChatInput.placeholder.whatAreYouBuilding', "What are you building?"), + localize('sessionsChatInput.placeholder.whatWillYouShipToday', "What will you ship today?"), + localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build..."), + localize('sessionsChatInput.placeholder.whatsYourNextMilestone', "What's your next milestone?"), + localize('sessionsChatInput.placeholder.whatAreYouTryingToAchieve', "What are you trying to achieve?"), + localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea..."), + localize('sessionsChatInput.placeholder.whatsTheGoal', "What's the goal?"), + localize('sessionsChatInput.placeholder.whatWillYouCreate', "What will you create?"), + localize('sessionsChatInput.placeholder.whatFeatureAreYouDreamingUp', "What feature are you dreaming up?"), + localize('sessionsChatInput.placeholder.describeTheOutcome', "Describe the outcome you want..."), + localize('sessionsChatInput.placeholder.whatProblemAreYouSolving', "What problem are you solving?"), + localize('sessionsChatInput.placeholder.whatsNextOnYourRoadmap', "What's next on your roadmap?"), + localize('sessionsChatInput.placeholder.whatWouldYouLikeToAutomate', "What would you like to automate?"), + localize('sessionsChatInput.placeholder.whatWillYouLaunch', "What will you launch?"), + localize('sessionsChatInput.placeholder.describeYourMission', "Describe your mission..."), +]; + +let lastPlaceholderIndex = -1; function getRandomChatInputPlaceholder(): string { - const placeholders = [ - localize('sessionsChatInput.placeholder.whatAreYouBuilding', "What are you building?"), - localize('sessionsChatInput.placeholder.whatWillYouShipToday', "What will you ship today?"), - localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build..."), - localize('sessionsChatInput.placeholder.whatsYourNextMilestone', "What's your next milestone?"), - localize('sessionsChatInput.placeholder.whatAreYouTryingToAchieve', "What are you trying to achieve?"), - localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea..."), - localize('sessionsChatInput.placeholder.whatsTheGoal', "What's the goal?"), - localize('sessionsChatInput.placeholder.whatWillYouCreate', "What will you create?"), - localize('sessionsChatInput.placeholder.whatFeatureAreYouDreamingUp', "What feature are you dreaming up?"), - localize('sessionsChatInput.placeholder.describeTheOutcome', "Describe the outcome you want..."), - localize('sessionsChatInput.placeholder.whatProblemAreYouSolving', "What problem are you solving?"), - localize('sessionsChatInput.placeholder.whatsNextOnYourRoadmap', "What's next on your roadmap?"), - localize('sessionsChatInput.placeholder.whatWouldYouLikeToAutomate', "What would you like to automate?"), - localize('sessionsChatInput.placeholder.whatWillYouLaunch', "What will you launch?"), - localize('sessionsChatInput.placeholder.describeYourMission', "Describe your mission..."), - ]; - return placeholders[Math.floor(Math.random() * placeholders.length)]; + let index = Math.floor(Math.random() * RANDOM_PLACEHOLDERS.length); + if (index === lastPlaceholderIndex) { + index = (index + 1) % RANDOM_PLACEHOLDERS.length; + } + lastPlaceholderIndex = index; + return RANDOM_PLACEHOLDERS[index]; } // #region --- New Chat Widget --- From 15d4bf6c4d6b35742397096917f140c0011ff234 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 21 Apr 2026 10:21:47 +0100 Subject: [PATCH 06/23] feat(chat): refine chat input placeholders by removing ellipses for clarity --- src/vs/sessions/contrib/chat/browser/newChatInput.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index d8bad65a7745a..60be30e47862c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -67,19 +67,19 @@ interface IDraftState { const RANDOM_PLACEHOLDERS = [ localize('sessionsChatInput.placeholder.whatAreYouBuilding', "What are you building?"), localize('sessionsChatInput.placeholder.whatWillYouShipToday', "What will you ship today?"), - localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build..."), + localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build"), localize('sessionsChatInput.placeholder.whatsYourNextMilestone', "What's your next milestone?"), localize('sessionsChatInput.placeholder.whatAreYouTryingToAchieve', "What are you trying to achieve?"), - localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea..."), + localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea"), localize('sessionsChatInput.placeholder.whatsTheGoal', "What's the goal?"), localize('sessionsChatInput.placeholder.whatWillYouCreate', "What will you create?"), localize('sessionsChatInput.placeholder.whatFeatureAreYouDreamingUp', "What feature are you dreaming up?"), - localize('sessionsChatInput.placeholder.describeTheOutcome', "Describe the outcome you want..."), + localize('sessionsChatInput.placeholder.describeTheOutcome', "Describe the outcome you want"), localize('sessionsChatInput.placeholder.whatProblemAreYouSolving', "What problem are you solving?"), localize('sessionsChatInput.placeholder.whatsNextOnYourRoadmap', "What's next on your roadmap?"), localize('sessionsChatInput.placeholder.whatWouldYouLikeToAutomate', "What would you like to automate?"), localize('sessionsChatInput.placeholder.whatWillYouLaunch', "What will you launch?"), - localize('sessionsChatInput.placeholder.describeYourMission', "Describe your mission..."), + localize('sessionsChatInput.placeholder.describeYourMission', "Describe your mission"), ]; let lastPlaceholderIndex = -1; From 16e7e398e85136a93561ba285480abd11b743194 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:32:41 +0000 Subject: [PATCH 07/23] Agents - disable git commands in the editor title (#311645) --- extensions/git/package.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/git/package.json b/extensions/git/package.json index 0af7ea212689b..17ddbced72d93 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -2770,7 +2770,7 @@ { "command": "git.openChange", "group": "navigation@2", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges && !isSessionsWindow" }, { "command": "git.stashApplyEditor", @@ -2786,37 +2786,37 @@ { "command": "git.stage", "group": "2_git@1", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges && !isSessionsWindow" }, { "command": "git.unstage", "group": "2_git@2", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges && !isSessionsWindow" }, { "command": "git.stage", "group": "2_git@1", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow" }, { "command": "git.stageSelectedRanges", "group": "2_git@2", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow" }, { "command": "git.unstage", "group": "2_git@3", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git && !isSessionsWindow" }, { "command": "git.unstageSelectedRanges", "group": "2_git@4", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git && !isSessionsWindow" }, { "command": "git.revertSelectedRanges", "group": "2_git@5", - "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file" + "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow" } ], "editor/context": [ From 38df135823f9b6892f1eda362762800366922d16 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:36:33 +0000 Subject: [PATCH 08/23] Agents - add open file/open changes actions (#311648) --- extensions/copilot/package.json | 2 +- .../changes/browser/changesViewActions.ts | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 9028db141f0ff..084c2af0769be 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5682,7 +5682,7 @@ { "command": "github.copilot.sessions.discardChanges", "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.changesVersionMode == branchChanges", - "group": "navigation@1" + "group": "navigation@2" } ], "chat/customizations/create": [ diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 86ec33bab13fc..9a26ca63017a5 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -26,6 +26,9 @@ import { ViewContainerLocation } from '../../../../workbench/common/views.js'; import { ChangesViewPane } from './changesView.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../files/browser/files.contribution.js'; import { SESSIONS_FILES_VIEW_ID } from '../../files/browser/filesView.js'; +import { URI } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; const openChangesViewActionOptions: IAction2Options = { id: 'workbench.action.agentSessions.openChangesView', @@ -197,3 +200,71 @@ class OpenPullRequestAction extends Action2 { } registerAction2(OpenPullRequestAction); + +class OpenFileAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.openFile'; + + constructor() { + super({ + id: OpenFileAction.ID, + title: localize2('openFile', "Open File"), + icon: Codicon.goToFile, + f1: false, + menu: { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 1, + when: IsSessionsWindowContext, + alt: { + id: 'workbench.action.agentSessions.openChanges', + title: localize2('openChanges', "Open Changes"), + icon: Codicon.gitCompare, + } + } + }); + } + + async run(accessor: ServicesAccessor, _sessionResource: URI, _ref: string, ...resources: URI[]): Promise { + const editorService = accessor.get(IEditorService); + await Promise.all(resources.map(resource => editorService.openEditor({ resource }))); + } +} + +registerAction2(OpenFileAction); + +class OpenChangesAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.openChanges'; + + constructor() { + super({ + id: OpenChangesAction.ID, + title: localize2('openChanges', "Open Changes"), + icon: Codicon.gitCompare, + f1: false + }); + } + + async run(accessor: ServicesAccessor, _sessionResource: URI, _ref: string, ...resources: URI[]): Promise { + const viewsService = accessor.get(IViewsService); + const editorService = accessor.get(IEditorService); + + const view = viewsService.getViewWithId(CHANGES_VIEW_ID); + const changes = view?.viewModel.activeSessionChangesObs.get(); + + for (const resource of resources) { + const change = changes?.find(change => + isEqual(change.modifiedUri ?? change.originalUri, resource)); + + if (!change) { + continue; + } + + await editorService.openEditor({ + original: { resource: change.originalUri }, + modified: { resource: change.modifiedUri }, + }); + } + } +} + +registerAction2(OpenChangesAction); From 232ae44e9223576ad564451711f983ce878a76f3 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 21 Apr 2026 21:10:23 +1000 Subject: [PATCH 09/23] Implement plan review feedback functionality with UI enhancements (#311632) * feat(chat): add plan review feedback functionality with editor integration Co-authored-by: Copilot * feat(chat): implement plan review feedback functionality with editor actions and UI enhancements Co-authored-by: Copilot * Udpates * Fixes * feat(chat): enhance plan review feedback registration logic to allow feedback only when applicable Co-authored-by: Copilot * Updates --------- Co-authored-by: Copilot --- .../contrib/chat/browser/chat.contribution.ts | 5 + .../media/planReviewFeedback.css | 160 ++++ .../planReviewFeedbackEditorActions.ts | 169 ++++ .../planReviewFeedbackEditorContribution.ts | 723 ++++++++++++++++++ .../planReviewFeedbackService.ts | 210 +++++ .../chatContentParts/chatPlanReviewPart.ts | 53 +- .../browser/planReviewFeedbackService.test.ts | 356 +++++++++ 7 files changed, 1674 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/planReviewFeedback/media/planReviewFeedback.css create mode 100644 src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorContribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/planReviewFeedbackService.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 2774b967bfa67..f01e22e33658d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -168,6 +168,9 @@ import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { PluginInstallService } from './pluginInstallService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; +import './planReviewFeedback/planReviewFeedbackEditorContribution.js'; +import { registerPlanReviewFeedbackEditorActions } from './planReviewFeedback/planReviewFeedbackEditorActions.js'; +import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; import { ChatSlashCommandsContribution } from './chatSlashCommands.js'; import { PluginUrlHandler } from './pluginUrlHandler.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; @@ -2164,6 +2167,7 @@ registerChatElicitationActions(); registerChatToolActions(); registerLanguageModelActions(); registerChatPluginActions(); +registerPlanReviewFeedbackEditorActions(); registerAction2(ConfigureToolSets); registerEditorFeature(ChatPasteProvidersFeature); @@ -2209,6 +2213,7 @@ registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.D registerSingleton(IChatArtifactsService, ChatArtifactsService, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); +registerSingleton(IPlanReviewFeedbackService, PlanReviewFeedbackService, InstantiationType.Delayed); registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); registerSingleton(IChatDebugService, ChatDebugServiceImpl, InstantiationType.Delayed); registerSingleton(IChatImageCarouselService, ChatImageCarouselService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/planReviewFeedback/media/planReviewFeedback.css b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/media/planReviewFeedback.css new file mode 100644 index 0000000000000..a00c20cdca301 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/media/planReviewFeedback.css @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.plan-review-feedback-input-widget { + position: absolute; + z-index: 10000; + background-color: var(--vscode-panel-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + box-shadow: var(--vscode-shadow-lg); + border-radius: 8px; + padding: 4px; + display: flex; + flex-direction: row; + align-items: flex-end; + animation: planReviewFeedbackInputAppear 0.15s ease-out; +} + +@keyframes planReviewFeedbackInputAppear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .plan-review-feedback-input-widget { + animation: none; + transform: none; + } +} + +.plan-review-feedback-input-widget textarea { + background-color: var(--vscode-panel-background); + border: none; + color: var(--vscode-input-foreground); + font: inherit; + border-radius: 4px; + padding: 0 0 0 6px; + outline: none; + min-width: 150px; + max-width: 400px; + resize: none; + overflow-y: hidden; + white-space: pre-wrap; + word-wrap: break-word; + box-sizing: border-box; + display: block; + flex: 1; +} + +.plan-review-feedback-input-widget textarea:focus { + border-color: var(--vscode-focusBorder); + outline: none !important; +} + +.plan-review-feedback-input-widget textarea::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.plan-review-feedback-input-widget .plan-review-feedback-input-measure { + position: absolute; + visibility: hidden; + height: 0; + overflow: hidden; + white-space: pre; + font: inherit; + font-size: 13px; +} + +.plan-review-feedback-input-widget .plan-review-feedback-input-actions { + display: flex; + align-items: center; + margin-left: 2px; + flex-shrink: 0; +} + +.plan-review-feedback-input-widget .plan-review-feedback-input-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} + +/* Overlay toolbar (submit / navigate / clear actions) */ +.plan-review-feedback-overlay-widget { + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; + border: 1px solid var(--vscode-editorHoverWidget-border); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 10; + box-shadow: var(--vscode-shadow-lg); + overflow: hidden; +} + +.plan-review-feedback-overlay-widget .action-item > .action-label { + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; +} + +.plan-review-feedback-overlay-widget .monaco-action-bar .actions-container { + gap: 4px; +} + +.plan-review-feedback-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .plan-review-feedback-overlay-widget .monaco-action-bar .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.plan-review-feedback-overlay-widget .action-item > .action-label.codicon:not(.separator) { + color: var(--vscode-foreground); + width: 22px; + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + +.plan-review-feedback-overlay-widget .monaco-action-bar .action-item.disabled { + + > .action-label.codicon::before, + > .action-label.codicon, + > .action-label, + > .action-label:hover { + color: var(--vscode-button-separator); + opacity: 1; + } +} + +.plan-review-feedback-overlay-widget .action-item.label-item { + font-variant-numeric: tabular-nums; +} + +.plan-review-feedback-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, +.plan-review-feedback-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { + color: var(--vscode-foreground); + opacity: 1; +} + +/* Gutter decoration for feedback lines */ +.plan-review-feedback-line-number { + color: var(--vscode-editorInfo-foreground); +} diff --git a/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorActions.ts b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorActions.ts new file mode 100644 index 0000000000000..e10a1da941364 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorActions.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * 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, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { isCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { IPlanReviewFeedbackService } from './planReviewFeedbackService.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; + +export const PlanReviewFeedbackMenuId = MenuId.for('planReviewFeedback.editorContent'); + +export const hasPlanReviewFeedback = new RawContextKey('planReviewFeedback.hasFeedback', false); + +export const submitPlanReviewFeedbackActionId = 'planReviewFeedback.action.submit'; +export const navigatePreviousPlanReviewFeedbackActionId = 'planReviewFeedback.action.navigatePrevious'; +export const navigateNextPlanReviewFeedbackActionId = 'planReviewFeedback.action.navigateNext'; +export const clearAllPlanReviewFeedbackActionId = 'planReviewFeedback.action.clearAll'; +export const navigationBearingFakeActionId = 'planReviewFeedback.navigation.bearings'; + +function getActivePlanUri(accessor: ServicesAccessor): URI | undefined { + const editorService = accessor.get(IEditorService); + const planReviewFeedbackService = accessor.get(IPlanReviewFeedbackService); + + const activeEditor = editorService.activeEditor; + if (!activeEditor) { + return undefined; + } + + const resource = activeEditor.resource; + if (!resource) { + return undefined; + } + + if (planReviewFeedbackService.isActivePlanReview(resource)) { + return resource; + } + + return undefined; +} + +class SubmitPlanReviewFeedbackAction extends Action2 { + + constructor() { + super({ + id: submitPlanReviewFeedbackActionId, + title: localize2('planReviewFeedback.submit', 'Submit Feedback'), + shortTitle: localize2('planReviewFeedback.submitShort', 'Submit'), + icon: Codicon.send, + category: CHAT_CATEGORY, + precondition: hasPlanReviewFeedback, + menu: { + id: PlanReviewFeedbackMenuId, + group: 'a_submit', + order: 0, + when: hasPlanReviewFeedback, + }, + }); + } + + override run(accessor: ServicesAccessor): void { + const planUri = getActivePlanUri(accessor); + if (!planUri) { + return; + } + + const planReviewFeedbackService = accessor.get(IPlanReviewFeedbackService); + planReviewFeedbackService.submitAllFeedback(planUri); + } +} + +class NavigatePlanReviewFeedbackAction extends Action2 { + + constructor(private readonly _next: boolean) { + super({ + id: _next ? navigateNextPlanReviewFeedbackActionId : navigatePreviousPlanReviewFeedbackActionId, + title: _next + ? localize2('planReviewFeedback.next', 'Go to Next Feedback Comment') + : localize2('planReviewFeedback.previous', 'Go to Previous Feedback Comment'), + icon: _next ? Codicon.arrowDown : Codicon.arrowUp, + category: CHAT_CATEGORY, + f1: true, + precondition: hasPlanReviewFeedback, + menu: { + id: PlanReviewFeedbackMenuId, + group: 'navigate', + order: _next ? 2 : 1, + when: hasPlanReviewFeedback, + }, + }); + } + + override run(accessor: ServicesAccessor): void { + const planUri = getActivePlanUri(accessor); + if (!planUri) { + return; + } + + const planReviewFeedbackService = accessor.get(IPlanReviewFeedbackService); + const editorService = accessor.get(IEditorService); + + const item = planReviewFeedbackService.getNextFeedback(planUri, this._next); + if (!item) { + return; + } + + // Reveal the feedback item in the editor + const editor = editorService.activeTextEditorControl; + if (editor && isCodeEditor(editor)) { + editor.revealLineInCenter(item.line); + editor.setPosition({ lineNumber: item.line, column: item.column }); + } + } +} + +class ClearAllPlanReviewFeedbackAction extends Action2 { + + constructor() { + super({ + id: clearAllPlanReviewFeedbackActionId, + title: localize2('planReviewFeedback.clear', 'Clear'), + tooltip: localize2('planReviewFeedback.clearAllTooltip', 'Clear All Feedback'), + icon: Codicon.clearAll, + category: CHAT_CATEGORY, + f1: true, + precondition: hasPlanReviewFeedback, + menu: { + id: PlanReviewFeedbackMenuId, + group: 'a_submit', + order: 1, + when: hasPlanReviewFeedback, + }, + }); + } + + override run(accessor: ServicesAccessor): void { + const planUri = getActivePlanUri(accessor); + if (!planUri) { + return; + } + + const planReviewFeedbackService = accessor.get(IPlanReviewFeedbackService); + planReviewFeedbackService.clearFeedback(planUri); + } +} + +export function registerPlanReviewFeedbackEditorActions(): void { + registerAction2(SubmitPlanReviewFeedbackAction); + registerAction2(class extends NavigatePlanReviewFeedbackAction { constructor() { super(false); } }); + registerAction2(class extends NavigatePlanReviewFeedbackAction { constructor() { super(true); } }); + registerAction2(ClearAllPlanReviewFeedbackAction); + + MenuRegistry.appendMenuItem(PlanReviewFeedbackMenuId, { + command: { + id: navigationBearingFakeActionId, + title: localize('label', 'Navigation Status'), + precondition: ContextKeyExpr.false(), + }, + group: 'navigate', + order: -1, + when: hasPlanReviewFeedback, + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorContribution.ts b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorContribution.ts new file mode 100644 index 0000000000000..a1e651e4a23a5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackEditorContribution.ts @@ -0,0 +1,723 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/planReviewFeedback.css'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from '../../../../../editor/browser/editorBrowser.js'; +import { IEditorContribution, IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../../editor/browser/editorExtensions.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; +import { SelectionDirection } from '../../../../../editor/common/core/selection.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { OverviewRulerLane } from '../../../../../editor/common/model.js'; +import { themeColorFromId } from '../../../../../platform/theme/common/themeService.js'; +import { overviewRulerInfo } from '../../../../../editor/common/core/editorColorRegistry.js'; +import { addStandardDisposableListener, getWindow, ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { localize } from '../../../../../nls.js'; +import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { IPlanReviewFeedbackService } from './planReviewFeedbackService.js'; +import { hasPlanReviewFeedback, navigationBearingFakeActionId, PlanReviewFeedbackMenuId, submitPlanReviewFeedbackActionId } from './planReviewFeedbackEditorActions.js'; +import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { autorun, observableValue } from '../../../../../base/common/observable.js'; + +class PlanReviewFeedbackInputWidget implements IOverlayWidget { + + private static readonly _ID = 'planReviewFeedback.inputWidget'; + private static readonly _MIN_WIDTH = 150; + private static readonly _MAX_WIDTH = 400; + + readonly allowEditorOverflow = false; + + private readonly _domNode: HTMLElement; + private readonly _inputElement: HTMLTextAreaElement; + private readonly _measureElement: HTMLElement; + private readonly _actionBar: ActionBar; + private readonly _addAction: Action; + private readonly _addAndSubmitAction: Action; + private _position: IOverlayWidgetPosition | null = null; + private _lineHeight = 22; + + private readonly _onDidTriggerAdd = new Emitter(); + readonly onDidTriggerAdd: Event = this._onDidTriggerAdd.event; + + private readonly _onDidTriggerAddAndSubmit = new Emitter(); + readonly onDidTriggerAddAndSubmit: Event = this._onDidTriggerAddAndSubmit.event; + + constructor( + private readonly _editor: ICodeEditor, + ) { + this._domNode = document.createElement('div'); + this._domNode.classList.add('plan-review-feedback-input-widget'); + this._domNode.style.display = 'none'; + + this._inputElement = document.createElement('textarea'); + this._inputElement.rows = 1; + this._inputElement.placeholder = localize('planReviewFeedback.addFeedback', "Add Feedback"); + this._domNode.appendChild(this._inputElement); + + // Hidden element used to measure text width for auto-growing + this._measureElement = document.createElement('span'); + this._measureElement.classList.add('plan-review-feedback-input-measure'); + this._domNode.appendChild(this._measureElement); + + // Action bar with add/submit actions + const actionsContainer = document.createElement('div'); + actionsContainer.classList.add('plan-review-feedback-input-actions'); + this._domNode.appendChild(actionsContainer); + + this._addAction = new Action( + 'planReviewFeedback.add', + localize('planReviewFeedback.add', "Add Feedback (Enter)"), + ThemeIcon.asClassName(Codicon.plus), + false, + () => { this._onDidTriggerAdd.fire(); return Promise.resolve(); } + ); + + this._addAndSubmitAction = new Action( + 'planReviewFeedback.addAndSubmit', + localize('planReviewFeedback.addAndSubmit', "Add Feedback and Submit (Alt+Enter)"), + ThemeIcon.asClassName(Codicon.send), + false, + () => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); } + ); + + this._actionBar = new ActionBar(actionsContainer); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + + // Toggle to alt action when Alt key is held + const modifierKeyEmitter = ModifierKeyEmitter.getInstance(); + modifierKeyEmitter.event(status => { + this._updateActionForAlt(status.altKey); + }); + + this._inputElement.style.lineHeight = `${this._lineHeight}px`; + } + + private _isShowingAlt = false; + + private _updateActionForAlt(altKey: boolean): void { + if (altKey && !this._isShowingAlt) { + this._isShowingAlt = true; + this._actionBar.clear(); + this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") }); + } else if (!altKey && this._isShowingAlt) { + this._isShowingAlt = false; + this._actionBar.clear(); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + } + } + + getId(): string { + return PlanReviewFeedbackInputWidget._ID; + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return this._position; + } + + get inputElement(): HTMLTextAreaElement { + return this._inputElement; + } + + setPosition(position: IOverlayWidgetPosition | null): void { + this._position = position; + this._editor.layoutOverlayWidget(this); + } + + show(): void { + this._domNode.style.display = ''; + } + + hide(): void { + this._domNode.style.display = 'none'; + } + + clearInput(): void { + this._inputElement.value = ''; + this._updateActionEnabled(); + this._autoSize(); + } + + autoSize(): void { + this._autoSize(); + } + + updateActionEnabled(): void { + this._updateActionEnabled(); + } + + private _updateActionEnabled(): void { + const hasText = this._inputElement.value.trim().length > 0; + this._addAction.enabled = hasText; + this._addAndSubmitAction.enabled = hasText; + } + + private _autoSize(): void { + const text = this._inputElement.value || this._inputElement.placeholder; + + // Measure the text width using the hidden span + this._measureElement.textContent = text; + const textWidth = this._measureElement.scrollWidth; + + // Clamp width between min and max + const width = Math.max(PlanReviewFeedbackInputWidget._MIN_WIDTH, Math.min(textWidth + 10, PlanReviewFeedbackInputWidget._MAX_WIDTH)); + this._inputElement.style.width = `${width}px`; + + // Reset height to auto then expand to fit all content, with a minimum of 1 line + this._inputElement.style.height = 'auto'; + const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight); + this._inputElement.style.height = `${newHeight}px`; + } + + dispose(): void { + this._actionBar.dispose(); + this._addAction.dispose(); + this._addAndSubmitAction.dispose(); + this._onDidTriggerAdd.dispose(); + this._onDidTriggerAddAndSubmit.dispose(); + } +} + +class PlanReviewFeedbackOverlayWidget implements IOverlayWidget { + + private static readonly _ID = 'planReviewFeedback.overlayWidget'; + + private readonly _domNode: HTMLElement; + private readonly _toolbarNode: HTMLElement; + private readonly _showStore = new DisposableStore(); + private readonly _navigationBearings = observableValue<{ activeIdx: number; totalCount: number }>('planReviewFeedbackBearings', { activeIdx: -1, totalCount: 0 }); + + constructor( + _codeEditor: ICodeEditor, + private readonly _instaService: IInstantiationService, + ) { + this._domNode = document.createElement('div'); + this._domNode.classList.add('plan-review-feedback-overlay-widget'); + this._domNode.style.display = 'none'; + + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('plan-review-feedback-overlay-toolbar'); + this._domNode.appendChild(this._toolbarNode); + } + + getId(): string { + return PlanReviewFeedbackOverlayWidget._ID; + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return { preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER }; + } + + show(navigationBearings: { activeIdx: number; totalCount: number }): void { + this._showStore.clear(); + this._navigationBearings.set(navigationBearings, undefined); + this._domNode.style.display = ''; + + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, PlanReviewFeedbackMenuId, { + telemetrySource: 'planReviewFeedback.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true, + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + if (action.id === navigationBearingFakeActionId) { + const that = this; + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('label-item'); + + this._store.add(autorun(r => { + if (!this.label) { + return; + } + const { activeIdx, totalCount } = that._navigationBearings.read(r); + if (totalCount > 0) { + const current = activeIdx === -1 ? 1 : activeIdx + 1; + this.label.innerText = localize('nOfM', '{0}/{1}', current, totalCount); + } else { + this.label.innerText = localize('zero', '0/0'); + } + })); + } + }; + } + + const isPrimary = action.id === submitPlanReviewFeedbackActionId; + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: !isPrimary, label: isPrimary, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + if (isPrimary) { + this.element?.classList.add('primary'); + } + } + }; + }, + })); + } + + hide(): void { + this._showStore.clear(); + this._domNode.style.display = 'none'; + this._navigationBearings.set({ activeIdx: -1, totalCount: 0 }, undefined); + } + + dispose(): void { + this._showStore.dispose(); + } +} + +export class PlanReviewFeedbackEditorContribution extends Disposable implements IEditorContribution { + + static readonly ID = 'planReviewFeedback.editorContribution'; + + private _widget: PlanReviewFeedbackInputWidget | undefined; + private _overlayWidget: PlanReviewFeedbackOverlayWidget | undefined; + private _visible = false; + private _mouseDown = false; + private _suppressSelectionChangeOnce = false; + private _isActivePlan = false; + private readonly _widgetListeners = this._register(new DisposableStore()); + private readonly _decorations: IEditorDecorationsCollection; + private readonly _hasFeedbackContextKey; + + constructor( + private readonly _editor: ICodeEditor, + @IPlanReviewFeedbackService private readonly _planReviewFeedbackService: IPlanReviewFeedbackService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this._decorations = this._editor.createDecorationsCollection(); + this._hasFeedbackContextKey = hasPlanReviewFeedback.bindTo(contextKeyService); + + this._register(this._editor.onDidChangeCursorSelection(() => this._onSelectionChanged())); + this._register(this._editor.onDidChangeModel(() => this._onModelChanged())); + this._register(this._editor.onDidScrollChange(() => { + if (this._visible) { + this._updatePosition(); + } + })); + this._register(this._editor.onMouseDown((e) => { + if (this._isWidgetTarget(e.event.target)) { + return; + } + this._mouseDown = true; + this._hide(); + })); + this._register(this._editor.onMouseUp((e) => { + this._mouseDown = false; + if (this._isWidgetTarget(e.event.target)) { + return; + } + this._onSelectionChanged(); + })); + this._register(this._editor.onDidBlurEditorWidget(() => { + if (!this._visible) { + return; + } + getWindow(this._editor.getDomNode()!).setTimeout(() => { + if (!this._visible) { + return; + } + if (this._isWidgetTarget(getWindow(this._editor.getDomNode()!).document.activeElement)) { + return; + } + this._hide(); + }, 0); + })); + this._register(this._editor.onDidFocusEditorText(() => this._onSelectionChanged())); + this._register(this._planReviewFeedbackService.onDidChangeRegistrations(() => this._onModelChanged())); + this._register(this._planReviewFeedbackService.onDidChangeFeedback(() => this._updateDecorations())); + this._register(this._planReviewFeedbackService.onDidChangeNavigation(() => this._updateDecorations())); + } + + private _isWidgetTarget(target: EventTarget | Element | null): boolean { + return !!this._widget && !!target && this._widget.getDomNode().contains(target as Node); + } + + private _ensureWidget(): PlanReviewFeedbackInputWidget { + if (!this._widget) { + this._widget = new PlanReviewFeedbackInputWidget(this._editor); + this._register(this._widget.onDidTriggerAdd(() => this._addFeedback())); + this._register(this._widget.onDidTriggerAddAndSubmit(() => this._addFeedbackAndSubmit())); + this._editor.addOverlayWidget(this._widget); + } + return this._widget; + } + + private _onModelChanged(): void { + this._hide(); + this._suppressSelectionChangeOnce = false; + + const model = this._editor.getModel(); + this._isActivePlan = !!model && this._planReviewFeedbackService.isActivePlanReview(model.uri); + this._updateDecorations(); + } + + private _onSelectionChanged(): void { + if (this._suppressSelectionChangeOnce) { + this._suppressSelectionChangeOnce = false; + return; + } + + if (this._mouseDown || !this._editor.hasTextFocus()) { + return; + } + + if (!this._isActivePlan) { + this._hide(); + return; + } + + const selection = this._editor.getSelection(); + if (!selection) { + this._hide(); + return; + } + + const model = this._editor.getModel(); + if (!model) { + this._hide(); + return; + } + + this._show(); + } + + private _show(): void { + const widget = this._ensureWidget(); + + if (!this._visible) { + this._visible = true; + this._registerWidgetListeners(widget); + } + + widget.clearInput(); + widget.show(); + this._updatePosition(); + } + + private _hide(): void { + if (!this._visible) { + return; + } + + this._visible = false; + this._widgetListeners.clear(); + + if (this._widget) { + this._widget.hide(); + this._widget.setPosition(null); + this._widget.clearInput(); + } + } + + private _registerWidgetListeners(widget: PlanReviewFeedbackInputWidget): void { + this._widgetListeners.clear(); + + const editorDomNode = this._editor.getDomNode(); + if (editorDomNode) { + this._widgetListeners.add(addStandardDisposableListener(editorDomNode, 'keydown', e => { + if (!this._visible) { + return; + } + + if (!this._editor.hasTextFocus()) { + return; + } + + if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) { + return; + } + + if (e.keyCode === KeyCode.Escape) { + this._hide(); + this._editor.focus(); + return; + } + + // Ctrl+I / Cmd+I explicitly focuses the feedback input + if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyI) { + e.preventDefault(); + e.stopPropagation(); + widget.inputElement.focus(); + return; + } + + if (e.ctrlKey || e.altKey || e.metaKey) { + return; + } + + // Keep caret/navigation keys in the editor + if ( + e.keyCode === KeyCode.UpArrow + || e.keyCode === KeyCode.DownArrow + || e.keyCode === KeyCode.LeftArrow + || e.keyCode === KeyCode.RightArrow + ) { + return; + } + + // Only auto-focus the input on typing when the document is readonly + if (!this._editor.getOption(EditorOption.readOnly)) { + return; + } + + if (getWindow(widget.inputElement).document.activeElement !== widget.inputElement) { + widget.inputElement.focus(); + } + })); + } + + // Listen for keydown on the input element + this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keydown', e => { + if (e.keyCode === KeyCode.Escape) { + e.preventDefault(); + e.stopPropagation(); + this._hide(); + this._editor.focus(); + return; + } + + if (e.keyCode === KeyCode.Enter && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._addFeedbackAndSubmit(); + return; + } + + if (e.keyCode === KeyCode.Enter) { + e.preventDefault(); + e.stopPropagation(); + this._addFeedback(); + return; + } + })); + + // Stop propagation of input events so the editor doesn't handle them + this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keypress', e => { + e.stopPropagation(); + })); + + // Auto-size the textarea as the user types + this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'input', () => { + widget.autoSize(); + widget.updateActionEnabled(); + this._updatePosition(); + })); + + // Hide when input loses focus to something outside both editor and widget + this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'blur', () => { + const win = getWindow(widget.inputElement); + win.setTimeout(() => { + if (!this._visible) { + return; + } + if (this._editor.hasWidgetFocus()) { + return; + } + this._hide(); + }, 0); + })); + } + + private _hideAndRefocusEditor(): void { + this._suppressSelectionChangeOnce = true; + this._hide(); + this._editor.focus(); + } + + private _addFeedback(): boolean { + if (!this._widget) { + return false; + } + + const text = this._widget.inputElement.value.trim(); + if (!text) { + return false; + } + + const selection = this._editor.getSelection(); + const model = this._editor.getModel(); + if (!selection || !model) { + return false; + } + + const line = selection.startLineNumber; + const column = selection.startColumn; + this._planReviewFeedbackService.addFeedback(model.uri, line, column, text); + this._hideAndRefocusEditor(); + return true; + } + + private _addFeedbackAndSubmit(): void { + if (!this._widget) { + return; + } + + const text = this._widget.inputElement.value.trim(); + if (!text) { + return; + } + + const selection = this._editor.getSelection(); + const model = this._editor.getModel(); + if (!selection || !model) { + return; + } + + const line = selection.startLineNumber; + const column = selection.startColumn; + this._planReviewFeedbackService.addFeedback(model.uri, line, column, text); + this._hideAndRefocusEditor(); + this._planReviewFeedbackService.submitAllFeedback(model.uri); + } + + private _updatePosition(): void { + if (!this._widget || !this._visible) { + return; + } + + const selection = this._editor.getSelection(); + if (!selection) { + this._hide(); + return; + } + + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const layoutInfo = this._editor.getLayoutInfo(); + const widgetDom = this._widget.getDomNode(); + const widgetHeight = widgetDom.offsetHeight || 30; + const widgetWidth = widgetDom.offsetWidth || 150; + + const cursorPosition = selection.getDirection() === SelectionDirection.LTR + ? selection.getEndPosition() + : selection.getStartPosition(); + + const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition); + if (!scrolledPosition) { + this._widget.setPosition(null); + return; + } + + // Place below for LTR, above for RTL, with flip if out of bounds + let top: number; + if (selection.getDirection() === SelectionDirection.LTR) { + top = scrolledPosition.top + lineHeight; + if (top + widgetHeight > layoutInfo.height) { + top = scrolledPosition.top - widgetHeight; + } + } else { + top = scrolledPosition.top - widgetHeight; + if (top < 0) { + top = scrolledPosition.top + lineHeight; + } + } + + top = Math.max(0, Math.min(top, layoutInfo.height - widgetHeight)); + const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth)); + + this._widget.setPosition({ preference: { top, left } }); + } + + private _updateDecorations(): void { + const model = this._editor.getModel(); + if (!model || !this._isActivePlan) { + this._decorations.clear(); + this._hasFeedbackContextKey.set(false); + this._hideOverlayToolbar(); + return; + } + + const items = this._planReviewFeedbackService.getFeedback(model.uri); + this._hasFeedbackContextKey.set(items.length > 0); + + if (items.length > 0) { + const bearings = this._planReviewFeedbackService.getNavigationBearing(model.uri); + this._showOverlayToolbar(bearings); + } else { + this._hideOverlayToolbar(); + } + + this._decorations.set( + items.map(item => ({ + range: new Range(item.line, item.column, item.line, item.column), + options: { + description: 'plan-review-feedback', + glyphMarginClassName: ThemeIcon.asClassName(Codicon.comment), + glyphMarginHoverMessage: { value: item.text }, + overviewRuler: { + color: themeColorFromId(overviewRulerInfo), + position: OverviewRulerLane.Center, + }, + lineNumberClassName: 'plan-review-feedback-line-number', + } + })) + ); + } + + private _ensureOverlayWidget(): PlanReviewFeedbackOverlayWidget { + if (!this._overlayWidget) { + this._overlayWidget = new PlanReviewFeedbackOverlayWidget(this._editor, this._instantiationService); + this._editor.addOverlayWidget(this._overlayWidget); + } + return this._overlayWidget; + } + + private _showOverlayToolbar(bearings: { activeIdx: number; totalCount: number }): void { + const widget = this._ensureOverlayWidget(); + widget.show(bearings); + } + + private _hideOverlayToolbar(): void { + if (this._overlayWidget) { + this._overlayWidget.hide(); + } + } + + override dispose(): void { + if (this._widget) { + this._editor.removeOverlayWidget(this._widget); + this._widget.dispose(); + this._widget = undefined; + } + if (this._overlayWidget) { + this._editor.removeOverlayWidget(this._overlayWidget); + this._overlayWidget.dispose(); + this._overlayWidget = undefined; + } + super.dispose(); + } +} + +registerEditorContribution(PlanReviewFeedbackEditorContribution.ID, PlanReviewFeedbackEditorContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackService.ts b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackService.ts new file mode 100644 index 0000000000000..f6d8279ae98ae --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planReviewFeedback/planReviewFeedbackService.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatPlanReviewResult } from '../../common/chatService/chatService.js'; + +export interface IPlanReviewFeedbackItem { + readonly id: string; + readonly line: number; + readonly column: number; + readonly text: string; +} + +export const IPlanReviewFeedbackService = createDecorator('planReviewFeedbackService'); + +export interface IPlanReviewFeedbackService { + readonly _serviceBrand: undefined; + + readonly onDidChangeFeedback: Event; + readonly onDidChangeNavigation: Event; + readonly onDidChangeRegistrations: Event; + + registerPlanReview(planUri: URI, onSubmit: (result: IChatPlanReviewResult) => void): IDisposable; + isActivePlanReview(uri: URI): boolean; + addFeedback(planUri: URI, line: number, column: number, text: string): string; + removeFeedback(planUri: URI, feedbackId: string): void; + updateFeedback(planUri: URI, feedbackId: string, newText: string): void; + getFeedback(planUri: URI): readonly IPlanReviewFeedbackItem[]; + clearFeedback(planUri: URI): void; + getNextFeedback(planUri: URI, next: boolean): IPlanReviewFeedbackItem | undefined; + getNavigationBearing(planUri: URI): { activeIdx: number; totalCount: number }; + setNavigationAnchor(planUri: URI, itemId: string | undefined): void; + submitAllFeedback(planUri: URI): void; +} + +interface IPlanReviewRegistration { + readonly onSubmit: (result: IChatPlanReviewResult) => void; + readonly items: IPlanReviewFeedbackItem[]; + navigationAnchor: string | undefined; +} + +export class PlanReviewFeedbackService extends Disposable implements IPlanReviewFeedbackService { + + declare readonly _serviceBrand: undefined; + + private readonly _registrations = new Map(); + + private readonly _onDidChangeFeedback = this._register(new Emitter()); + readonly onDidChangeFeedback: Event = this._onDidChangeFeedback.event; + + private readonly _onDidChangeNavigation = this._register(new Emitter()); + readonly onDidChangeNavigation: Event = this._onDidChangeNavigation.event; + + private readonly _onDidChangeRegistrations = this._register(new Emitter()); + readonly onDidChangeRegistrations: Event = this._onDidChangeRegistrations.event; + + registerPlanReview(planUri: URI, onSubmit: (result: IChatPlanReviewResult) => void): IDisposable { + const key = planUri.toString(); + this._registrations.set(key, { onSubmit, items: [], navigationAnchor: undefined }); + this._onDidChangeRegistrations.fire(); + return toDisposable(() => { + this._registrations.delete(key); + this._onDidChangeRegistrations.fire(); + }); + } + + isActivePlanReview(uri: URI): boolean { + return this._registrations.has(uri.toString()); + } + + addFeedback(planUri: URI, line: number, column: number, text: string): string { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration) { + return ''; + } + + const id = generateUuid(); + registration.items.push({ id, line, column, text }); + // Keep items sorted by line number + registration.items.sort((a, b) => a.line - b.line || a.column - b.column); + this._onDidChangeFeedback.fire(planUri); + return id; + } + + removeFeedback(planUri: URI, feedbackId: string): void { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration) { + return; + } + + const idx = registration.items.findIndex(item => item.id === feedbackId); + if (idx >= 0) { + registration.items.splice(idx, 1); + this._onDidChangeFeedback.fire(planUri); + } + } + + updateFeedback(planUri: URI, feedbackId: string, newText: string): void { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration) { + return; + } + + const idx = registration.items.findIndex(item => item.id === feedbackId); + if (idx >= 0) { + const old = registration.items[idx]; + registration.items[idx] = { id: old.id, line: old.line, column: old.column, text: newText }; + this._onDidChangeFeedback.fire(planUri); + } + } + + getFeedback(planUri: URI): readonly IPlanReviewFeedbackItem[] { + const key = planUri.toString(); + return this._registrations.get(key)?.items ?? []; + } + + clearFeedback(planUri: URI): void { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration || registration.items.length === 0) { + return; + } + registration.items.length = 0; + registration.navigationAnchor = undefined; + this._onDidChangeFeedback.fire(planUri); + } + + getNextFeedback(planUri: URI, next: boolean): IPlanReviewFeedbackItem | undefined { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration || registration.items.length === 0) { + return undefined; + } + + const items = registration.items; + const anchorIdx = registration.navigationAnchor + ? items.findIndex(item => item.id === registration.navigationAnchor) + : -1; + + let targetIdx: number; + if (anchorIdx === -1) { + targetIdx = next ? 0 : items.length - 1; + } else { + targetIdx = next + ? (anchorIdx + 1) % items.length + : (anchorIdx - 1 + items.length) % items.length; + } + + const target = items[targetIdx]; + this.setNavigationAnchor(planUri, target.id); + return target; + } + + getNavigationBearing(planUri: URI): { activeIdx: number; totalCount: number } { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration) { + return { activeIdx: -1, totalCount: 0 }; + } + + const totalCount = registration.items.length; + if (!registration.navigationAnchor) { + return { activeIdx: -1, totalCount }; + } + + const activeIdx = registration.items.findIndex(item => item.id === registration.navigationAnchor); + return { activeIdx, totalCount }; + } + + setNavigationAnchor(planUri: URI, itemId: string | undefined): void { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (registration) { + registration.navigationAnchor = itemId; + this._onDidChangeNavigation.fire(planUri); + } + } + + submitAllFeedback(planUri: URI): void { + const key = planUri.toString(); + const registration = this._registrations.get(key); + if (!registration || registration.items.length === 0) { + return; + } + + const formatted = this._formatFeedback(registration.items); + registration.onSubmit({ rejected: false, feedback: formatted }); + } + + private _formatFeedback(items: readonly IPlanReviewFeedbackItem[]): string { + const parts: string[] = ['Here\'s the feedback:']; + for (const item of items) { + if (item.column > 1) { + parts.push(`Line ${item.line}: Column ${item.column}: ${item.text}`); + } else { + parts.push(`Line ${item.line}: ${item.text}`); + } + } + return parts.join('\n'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index 5825d0f7dcbe0..4b35a3c2107a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -26,6 +26,7 @@ import { IMarkdownRendererService } from '../../../../../../platform/markdown/br import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IChatPlanApprovalAction, IChatPlanReview, IChatPlanReviewResult } from '../../../common/chatService/chatService.js'; +import { IPlanReviewFeedbackItem, IPlanReviewFeedbackService } from '../../planReviewFeedback/planReviewFeedbackService.js'; import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; @@ -60,6 +61,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { private _feedbackTextarea: HTMLTextAreaElement | undefined; private _feedbackSection: HTMLElement | undefined; private _isFeedbackMode = false; + private readonly _planReviewRegistration = this._register(new MutableDisposable()); constructor( public readonly review: IChatPlanReview, @@ -70,6 +72,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { @IDialogService private readonly _dialogService: IDialogService, @IEditorService private readonly _editorService: IEditorService, @IHoverService private readonly _hoverService: IHoverService, + @IPlanReviewFeedbackService private readonly _planReviewFeedbackService: IPlanReviewFeedbackService, ) { super(); @@ -82,6 +85,22 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { const isResponseComplete = isResponseVM(context.element) && context.element.isComplete; this._isSubmitted = !!review.isUsed || isResponseComplete; + // Register with the plan review feedback service so the editor + // contribution can show inline feedback input for this plan file. + // Only register when feedback is allowed and the review hasn't + // already been submitted. + if (review.planUri && review.canProvideFeedback && !this._isSubmitted) { + const planUri = URI.revive(review.planUri); + this._planReviewRegistration.value = this._planReviewFeedbackService.registerPlanReview(planUri, (result) => { + if (this._isSubmitted) { + return; + } + this._isSubmitted = true; + this._options.onSubmit(result); + this.markUsed(); + }); + } + // Build DOM that mirrors chat-confirmation-widget2 so we inherit its // styling (title bar, scrollable message, blue/grey button row). const elements = dom.h('.chat-confirmation-widget-container.chat-plan-review-container@container', [ @@ -424,10 +443,37 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { if (this._isSubmitted) { return; } - const feedback = this._feedbackTextarea?.value.trim(); - if (!feedback) { + const textareaFeedback = this._feedbackTextarea?.value.trim(); + + // Collect any inline editor feedback for this plan file. + let editorFeedbackItems: readonly IPlanReviewFeedbackItem[] = []; + if (this.review.planUri) { + const planUri = URI.revive(this.review.planUri); + editorFeedbackItems = this._planReviewFeedbackService.getFeedback(planUri); + } + + if (!textareaFeedback && editorFeedbackItems.length === 0) { return; } + + // Merge textarea feedback with editor-collected inline feedback. + const feedbackParts: string[] = []; + if (textareaFeedback) { + feedbackParts.push(textareaFeedback); + } + if (editorFeedbackItems.length > 0) { + const filePath = this.review.planUri?.path ? `(${this.review.planUri.path})` : ''; + feedbackParts.push(`Here's the feedback for contents of the plan file ${filePath}:`); + for (const item of editorFeedbackItems) { + if (item.column > 1) { + feedbackParts.push(`Line ${item.line}: Column ${item.column}: ${item.text}`); + } else { + feedbackParts.push(`Line ${item.line}: ${item.text}`); + } + } + } + + const feedback = feedbackParts.join('\n'); this._isSubmitted = true; this._options.onSubmit({ rejected: false, feedback }); this.markUsed(); @@ -460,6 +506,9 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { private markUsed(): void { this.domNode.classList.add('chat-plan-review-used'); this._buttonStore.clear(); + // Unregister from the feedback service so the editor contribution + // hides/disables immediately, even if the plan file is still open. + this._planReviewRegistration.clear(); if (this._feedbackTextarea) { this._feedbackTextarea.disabled = true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/planReviewFeedbackService.test.ts b/src/vs/workbench/contrib/chat/test/browser/planReviewFeedbackService.test.ts new file mode 100644 index 0000000000000..89f10dc5c576d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/planReviewFeedbackService.test.ts @@ -0,0 +1,356 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IPlanReviewFeedbackService, PlanReviewFeedbackService } from '../../browser/planReviewFeedback/planReviewFeedbackService.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; + +function feedbackSummary(items: readonly { line: number; column: number }[]): string[] { + return items.map(f => `${f.line}:${f.column}`); +} + +suite('PlanReviewFeedbackService - Ordering', () => { + + const store = new DisposableStore(); + let service: IPlanReviewFeedbackService; + let planUri: URI; + + setup(() => { + service = store.add(new PlanReviewFeedbackService()); + planUri = URI.parse('file:///plan.md'); + store.add(service.registerPlanReview(planUri, () => { })); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('items sorted by line number', () => { + service.addFeedback(planUri, 20, 1, 'line 20'); + service.addFeedback(planUri, 5, 1, 'line 5'); + service.addFeedback(planUri, 10, 1, 'line 10'); + + assert.deepStrictEqual(feedbackSummary(service.getFeedback(planUri)), [ + '5:1', + '10:1', + '20:1', + ]); + }); + + test('items sorted by line then column', () => { + service.addFeedback(planUri, 10, 20, 'col 20'); + service.addFeedback(planUri, 10, 5, 'col 5'); + service.addFeedback(planUri, 10, 10, 'col 10'); + + assert.deepStrictEqual(feedbackSummary(service.getFeedback(planUri)), [ + '10:5', + '10:10', + '10:20', + ]); + }); + + test('removing feedback preserves ordering', () => { + const id1 = service.addFeedback(planUri, 30, 1, 'line 30'); + service.addFeedback(planUri, 10, 1, 'line 10'); + service.addFeedback(planUri, 20, 1, 'line 20'); + + assert.deepStrictEqual(feedbackSummary(service.getFeedback(planUri)), [ + '10:1', + '20:1', + '30:1', + ]); + + service.removeFeedback(planUri, id1); + assert.deepStrictEqual(feedbackSummary(service.getFeedback(planUri)), [ + '10:1', + '20:1', + ]); + }); + + test('same line number items are stable', () => { + const id1 = service.addFeedback(planUri, 10, 1, 'first'); + const id2 = service.addFeedback(planUri, 10, 1, 'second'); + + const items = service.getFeedback(planUri); + assert.strictEqual(items[0].id, id1); + assert.strictEqual(items[1].id, id2); + }); + + test('clear removes all items', () => { + service.addFeedback(planUri, 1, 1, 'a'); + service.addFeedback(planUri, 2, 1, 'b'); + service.addFeedback(planUri, 3, 1, 'c'); + + assert.strictEqual(service.getFeedback(planUri).length, 3); + service.clearFeedback(planUri); + assert.strictEqual(service.getFeedback(planUri).length, 0); + }); + + test('update feedback changes text', () => { + const id = service.addFeedback(planUri, 10, 1, 'original'); + service.updateFeedback(planUri, id, 'updated'); + + const items = service.getFeedback(planUri); + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0].text, 'updated'); + assert.strictEqual(items[0].line, 10); + }); +}); + +suite('PlanReviewFeedbackService - Navigation', () => { + + const store = new DisposableStore(); + let service: IPlanReviewFeedbackService; + let planUri: URI; + + setup(() => { + service = store.add(new PlanReviewFeedbackService()); + planUri = URI.parse('file:///plan.md'); + store.add(service.registerPlanReview(planUri, () => { })); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('navigation follows sorted order', () => { + service.addFeedback(planUri, 20, 1, 'line 20'); + service.addFeedback(planUri, 5, 1, 'line 5'); + service.addFeedback(planUri, 10, 1, 'line 10'); + + // Expected order: 5, 10, 20 + const first = service.getNextFeedback(planUri, true)!; + assert.strictEqual(first.line, 5); + + const second = service.getNextFeedback(planUri, true)!; + assert.strictEqual(second.line, 10); + + const third = service.getNextFeedback(planUri, true)!; + assert.strictEqual(third.line, 20); + + // Wraps around + const fourth = service.getNextFeedback(planUri, true)!; + assert.strictEqual(fourth.line, 5); + }); + + test('navigation backwards', () => { + service.addFeedback(planUri, 5, 1, 'line 5'); + service.addFeedback(planUri, 10, 1, 'line 10'); + service.addFeedback(planUri, 20, 1, 'line 20'); + + // First backward nav goes to last item + const first = service.getNextFeedback(planUri, false)!; + assert.strictEqual(first.line, 20); + + const second = service.getNextFeedback(planUri, false)!; + assert.strictEqual(second.line, 10); + + const third = service.getNextFeedback(planUri, false)!; + assert.strictEqual(third.line, 5); + + // Wraps around + const fourth = service.getNextFeedback(planUri, false)!; + assert.strictEqual(fourth.line, 20); + }); + + test('navigation bearings reflect sorted position', () => { + service.addFeedback(planUri, 20, 1, 'line 20'); + service.addFeedback(planUri, 5, 1, 'line 5'); + service.addFeedback(planUri, 10, 1, 'line 10'); + + // Before navigation, no anchor + let bearing = service.getNavigationBearing(planUri); + assert.strictEqual(bearing.activeIdx, -1); + assert.strictEqual(bearing.totalCount, 3); + + // Navigate to first (5) + service.getNextFeedback(planUri, true); + bearing = service.getNavigationBearing(planUri); + assert.strictEqual(bearing.activeIdx, 0); + + // Navigate to second (10) + service.getNextFeedback(planUri, true); + bearing = service.getNavigationBearing(planUri); + assert.strictEqual(bearing.activeIdx, 1); + + // Navigate to third (20) + service.getNextFeedback(planUri, true); + bearing = service.getNavigationBearing(planUri); + assert.strictEqual(bearing.activeIdx, 2); + }); + + test('navigation returns undefined for empty feedback', () => { + const result = service.getNextFeedback(planUri, true); + assert.strictEqual(result, undefined); + }); + + test('setNavigationAnchor updates the anchor', () => { + const id = service.addFeedback(planUri, 10, 1, 'line 10'); + service.addFeedback(planUri, 20, 1, 'line 20'); + + service.setNavigationAnchor(planUri, id); + const bearing = service.getNavigationBearing(planUri); + assert.strictEqual(bearing.activeIdx, 0); + }); +}); + +suite('PlanReviewFeedbackService - Registration', () => { + + const store = new DisposableStore(); + let service: IPlanReviewFeedbackService; + + setup(() => { + service = store.add(new PlanReviewFeedbackService()); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('isActivePlanReview returns false before registration', () => { + const planUri = URI.parse('file:///plan.md'); + assert.strictEqual(service.isActivePlanReview(planUri), false); + }); + + test('isActivePlanReview returns true after registration', () => { + const planUri = URI.parse('file:///plan.md'); + store.add(service.registerPlanReview(planUri, () => { })); + assert.strictEqual(service.isActivePlanReview(planUri), true); + }); + + test('isActivePlanReview returns false after dispose', () => { + const planUri = URI.parse('file:///plan.md'); + const registration = service.registerPlanReview(planUri, () => { }); + assert.strictEqual(service.isActivePlanReview(planUri), true); + registration.dispose(); + assert.strictEqual(service.isActivePlanReview(planUri), false); + }); + + test('feedback cannot be added to unregistered plan', () => { + const planUri = URI.parse('file:///plan.md'); + const id = service.addFeedback(planUri, 1, 1, 'text'); + assert.strictEqual(id, ''); + assert.strictEqual(service.getFeedback(planUri).length, 0); + }); + + test('dispose clears feedback items', () => { + const planUri = URI.parse('file:///plan.md'); + const registration = service.registerPlanReview(planUri, () => { }); + service.addFeedback(planUri, 1, 1, 'text'); + assert.strictEqual(service.getFeedback(planUri).length, 1); + registration.dispose(); + assert.strictEqual(service.getFeedback(planUri).length, 0); + }); + + test('onDidChangeRegistrations fires on register and dispose', () => { + const planUri = URI.parse('file:///plan.md'); + let fireCount = 0; + store.add(service.onDidChangeRegistrations(() => fireCount++)); + + const registration = service.registerPlanReview(planUri, () => { }); + assert.strictEqual(fireCount, 1); + + registration.dispose(); + assert.strictEqual(fireCount, 2); + }); + + test('onDidChangeFeedback fires on add and remove', () => { + const planUri = URI.parse('file:///plan.md'); + store.add(service.registerPlanReview(planUri, () => { })); + + let fireCount = 0; + store.add(service.onDidChangeFeedback(() => fireCount++)); + + const id = service.addFeedback(planUri, 1, 1, 'text'); + assert.strictEqual(fireCount, 1); + + service.removeFeedback(planUri, id); + assert.strictEqual(fireCount, 2); + }); +}); + +suite('PlanReviewFeedbackService - Submit', () => { + + const store = new DisposableStore(); + let service: IPlanReviewFeedbackService; + + setup(() => { + service = store.add(new PlanReviewFeedbackService()); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('submitAllFeedback calls onSubmit with formatted feedback', () => { + const planUri = URI.parse('file:///plan.md'); + let submittedResult: { rejected: boolean; feedback?: string } | undefined; + store.add(service.registerPlanReview(planUri, (result) => { submittedResult = result; })); + + service.addFeedback(planUri, 1, 1, 'fix this'); + service.addFeedback(planUri, 45, 45, 'change that'); + + service.submitAllFeedback(planUri); + + assert.ok(submittedResult); + assert.strictEqual(submittedResult!.rejected, false); + assert.strictEqual(submittedResult!.feedback, [ + 'Here\'s the feedback:', + 'Line 1: fix this', + 'Line 45: Column 45: change that', + ].join('\n')); + }); + + test('submitAllFeedback does nothing when no items', () => { + const planUri = URI.parse('file:///plan.md'); + let called = false; + store.add(service.registerPlanReview(planUri, () => { called = true; })); + + service.submitAllFeedback(planUri); + assert.strictEqual(called, false); + }); + + test('feedback at column 1 omits column', () => { + const planUri = URI.parse('file:///plan.md'); + let submittedResult: { feedback?: string } | undefined; + store.add(service.registerPlanReview(planUri, (result) => { submittedResult = result; })); + + service.addFeedback(planUri, 10, 1, 'at start'); + + service.submitAllFeedback(planUri); + + assert.ok(submittedResult); + assert.strictEqual(submittedResult!.feedback, [ + 'Here\'s the feedback:', + 'Line 10: at start', + ].join('\n')); + }); + + test('feedback at column > 1 includes column', () => { + const planUri = URI.parse('file:///plan.md'); + let submittedResult: { feedback?: string } | undefined; + store.add(service.registerPlanReview(planUri, (result) => { submittedResult = result; })); + + service.addFeedback(planUri, 10, 15, 'mid line'); + + service.submitAllFeedback(planUri); + + assert.ok(submittedResult); + assert.strictEqual(submittedResult!.feedback, [ + 'Here\'s the feedback:', + 'Line 10: Column 15: mid line', + ].join('\n')); + }); +}); From df028283c5ca335b375406548bd5456a2f674d58 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 21 Apr 2026 12:11:17 +0100 Subject: [PATCH 10/23] Agents: Refine animated chat input working border (#311653) * Refine animated chat input working border - Refactor animated ring to a ::before pseudo-element with mask trick so it can fade in/out via opacity when the .working class toggles - Slow the spin from 1.2s to 3s for a calmer cadence - Replace the static pulsing box-shadow with a 3-step keyframed glow that cycles through the same three theme colors as the conic ring, in sync with the spin (so the halo appears to emanate from the gradient) - Drop the now-unused --chat-input-working-fill override in sessions and the matching entry in the stylelint allowlist * feat(chat): add working border colors for chat input in light and dark themes * feat(chat): update working border colors for chat input * Update src/vs/workbench/contrib/chat/browser/widget/media/chat.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(chat): add transition effect to chat input container --------- Co-authored-by: mrleemurray Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 1 - .../theme-defaults/themes/2026-dark.json | 3 + .../theme-defaults/themes/2026-light.json | 3 + src/vs/sessions/browser/media/style.css | 2 - .../chat/browser/widget/media/chat.css | 93 +++++++++++++------ 5 files changed, 69 insertions(+), 33 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index f8dcaac522a5f..5ecb1b22db968 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1041,7 +1041,6 @@ "--animation-angle", "--animation-opacity", "--chat-input-anim-angle", - "--chat-input-working-fill", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", "--vscode-chat-font-size-body-l", diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index a61ac87f4cc77..0561134ddad08 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -258,6 +258,9 @@ "gauge.errorBackground": "#F287724D", "chat.requestBubbleBackground": "#ffffff13", "chat.requestBubbleHoverBackground": "#ffffff22", + "chat.inputWorkingBorderColor1": "#9560D8", + "chat.inputWorkingBorderColor2": "#5BC25B", + "chat.inputWorkingBorderColor3": "#4A8FF5", "editorCommentsWidget.rangeBackground": "#488FAE26", "editorCommentsWidget.rangeActiveBackground": "#488FAE46", "charts.foreground": "#CCCCCC", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index a49294ee42ad6..1b24ca3a52ded 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -261,6 +261,9 @@ "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", "chat.thinkingShimmer": "#999999", + "chat.inputWorkingBorderColor1": "#9B30FF", + "chat.inputWorkingBorderColor2": "#00C853", + "chat.inputWorkingBorderColor3": "#0044FF", "editorCommentsWidget.rangeBackground": "#EEF4FB", "editorCommentsWidget.rangeActiveBackground": "#E6EDFA", "charts.foreground": "#202020", diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 53f46f7cf228f..60f72545d8ea2 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -387,8 +387,6 @@ border-color: var(--vscode-agentsChatInput-border) !important; background-color: var(--vscode-agentsChatInput-background); color: var(--vscode-agentsChatInput-foreground); - /* Preserve the agents-app input background under the developer-joy ring. */ - --chat-input-working-fill: var(--vscode-agentsChatInput-background); } .agent-sessions-workbench .interactive-session .chat-input-container.focused { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 05f8a04272fa6..0b419e07c820e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -861,6 +861,7 @@ have to be updated for changes to the rules above, or to support more deeply nes /* top padding is inside the editor widget */ width: 100%; position: relative; + transition: box-shadow 350ms ease; } /* Prevent contents from covering border corners. Not applied in compact mode @@ -873,10 +874,14 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Animated gradient border shown around the chat input while the agent is working or thinking. Toggled by the `chat.progressBorder.enabled` - setting and the chat widget's request-in-progress state. The ring is - rendered using layered `background-image` + `background-clip` - (`padding-box`/`border-box`), so it traces the input's outer corner - radius and isn't clipped by `overflow: hidden` on the parent. */ + setting and the chat widget's request-in-progress state. + + The ring is rendered as a `::before` pseudo-element so it can fade in + and out via `opacity` when the `.working` class is toggled, without + disturbing the input's own background. The pseudo uses a 1px padding + + inverted mask trick to paint a hairline gradient ring that follows + the input's corner radius. The three color stops are themeable via + `chat.inputWorkingBorderColor1/2/3`. */ @property --chat-input-anim-angle { syntax: ''; inherits: false; @@ -893,46 +898,74 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +/* Halo that cycles through the same three theme colors as the conic ring, + in sync with the 3s spin, so the glow appears to emanate from the + rotating gradient. */ @keyframes chat-input-working-border-glow { + 0% { + box-shadow: + 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 22%, transparent), + 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 10%, transparent); + } - 0%, - 100% { + 33% { box-shadow: - 0 0 4px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 18%, transparent), - 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 8%, transparent); + 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 22%, transparent), + 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 10%, transparent); } - 50% { + 66% { box-shadow: - 0 0 6px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 22%, transparent), - 0 0 14px 1px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 12%, transparent); + 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 22%, transparent), + 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 10%, transparent); } + + 100% { + box-shadow: + 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 22%, transparent), + 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 10%, transparent); + } +} + +.monaco-workbench .interactive-session .chat-input-container::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: conic-gradient(from var(--chat-input-anim-angle), + var(--vscode-chat-inputWorkingBorderColor1), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor3), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor1)); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 350ms ease; + pointer-events: none; + z-index: 1; } .monaco-workbench .interactive-session .chat-input-container.working { border-color: transparent; - /* The padding-box layer fills the input interior. It defaults to the - standard input background, but each host can override - `--chat-input-working-fill` to keep its own background color (e.g. the - Sessions workbench sets it to `--vscode-agentsChatInput-background`). - The conic-gradient layer is clipped to the border box so it paints - exactly where the (transparent) border lives. */ - background: - linear-gradient(var(--chat-input-working-fill, var(--vscode-input-background)), - var(--chat-input-working-fill, var(--vscode-input-background))) padding-box, - conic-gradient(from var(--chat-input-anim-angle), - var(--vscode-chat-inputWorkingBorderColor1), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor3), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor1)) border-box; - animation: - chat-input-working-border-spin 1.2s linear infinite, - chat-input-working-border-glow 3s ease-in-out infinite; + animation: chat-input-working-border-glow 3s linear infinite; +} + +.monaco-workbench .interactive-session .chat-input-container.working::before { + opacity: 1; + animation: chat-input-working-border-spin 3s linear infinite; } @media (prefers-reduced-motion: reduce) { - .monaco-workbench .interactive-session .chat-input-container.working { + .monaco-workbench .interactive-session .chat-input-container.working, + .monaco-workbench .interactive-session .chat-input-container.working::before { animation: none; } } From fa123b8220091a12e7fd2507056da2366f1cdd25 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:03:22 +0000 Subject: [PATCH 11/23] Agents - rework open changes action (#311665) --- .../changes/browser/changesViewActions.ts | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 9a26ca63017a5..d802caaabd1c3 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -211,7 +211,7 @@ class OpenFileAction extends Action2 { icon: Codicon.goToFile, f1: false, menu: { - id: MenuId.ChatEditingSessionChangesToolbar, + id: MenuId.ChatEditingSessionChangeToolbar, group: 'navigation', order: 1, when: IsSessionsWindowContext, @@ -249,21 +249,16 @@ class OpenChangesAction extends Action2 { const editorService = accessor.get(IEditorService); const view = viewsService.getViewWithId(CHANGES_VIEW_ID); - const changes = view?.viewModel.activeSessionChangesObs.get(); - - for (const resource of resources) { - const change = changes?.find(change => - isEqual(change.modifiedUri ?? change.originalUri, resource)); + const sessionChanges = view?.viewModel.activeSessionChangesObs.get(); - if (!change) { - continue; - } + const changes = sessionChanges?.filter(change => + resources.some(resource => isEqual(change.modifiedUri ?? change.originalUri, resource)) + ) ?? []; - await editorService.openEditor({ - original: { resource: change.originalUri }, - modified: { resource: change.modifiedUri }, - }); - } + await Promise.all(changes.map(change => editorService.openEditor({ + original: { resource: change.originalUri }, + modified: { resource: change.modifiedUri } + }))); } } From 4e26289a0682e28a2af18f9fbbbfc73d684d6d86 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 21 Apr 2026 22:03:45 +1000 Subject: [PATCH 12/23] fix: update @github/copilot to version 1.0.34 and integrate AutoModeSessionManager in session services (#311650) chore: update @github/copilot to version 1.0.34 and integrate AutoModeSessionManager in session services --- extensions/copilot/package-lock.json | 56 +++++++++---------- extensions/copilot/package.json | 2 +- .../node/copilotcliSessionService.ts | 7 ++- .../test/copilotCliSessionService.spec.ts | 18 +++--- .../copilotCLIChatSessionParticipant.spec.ts | 2 +- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 394b8dbcd09c1..e7b122547ef81 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -3203,26 +3203,26 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz", - "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.28", - "@github/copilot-darwin-x64": "1.0.28", - "@github/copilot-linux-arm64": "1.0.28", - "@github/copilot-linux-x64": "1.0.28", - "@github/copilot-win32-arm64": "1.0.28", - "@github/copilot-win32-x64": "1.0.28" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz", - "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -3236,9 +3236,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz", - "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -3252,9 +3252,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz", - "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -3268,9 +3268,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz", - "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -3284,9 +3284,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz", - "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -3300,9 +3300,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz", - "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c7e64badcda8d..8525cd4b5cb56 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6438,7 +6438,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 5b323ae81378c..3d670a8797f96 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -178,7 +178,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this.monitorSessionFiles(); this._sessionManager = new Lazy>(async () => { try { - const { internal, createLocalFeatureFlagService } = await this.getSDKPackage(); + const { internal, createLocalFeatureFlagService, AutoModeSessionManager } = await this.getSDKPackage(); // Always enable SDK OTel so the debug panel receives native spans via the bridge. // When user OTel is disabled, we force file exporter to /dev/null so the SDK // creates OtelSessionTracker (for debug panel) but doesn't export to any collector. @@ -210,6 +210,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return new internal.LocalSessionManager({ featureFlagService: createLocalFeatureFlagService(), telemetryService: new internal.NoopTelemetryService(), + autoModeManager: new AutoModeSessionManager(), }, { flushDebounceMs: undefined, settings: undefined, version: undefined }); } catch (error) { @@ -221,8 +222,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } private async getSDKPackage() { - const { internal, LocalSession, createLocalFeatureFlagService } = await this.copilotCLISDK.getPackage(); - return { internal, LocalSession, createLocalFeatureFlagService }; + const { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager } = await this.copilotCLISDK.getPackage(); + return { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager }; } getSessionWorkingDirectory(sessionId: string): Uri | undefined { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index d5018b35e1751..f41992be205e9 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -15,9 +15,11 @@ import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/com import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService'; import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; +import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; import { ILogService } from '../../../../../platform/log/common/logService'; import { NullMcpService } from '../../../../../platform/mcp/common/mcpService'; import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index'; +import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService'; import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger'; import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; import { mock } from '../../../../../util/common/test/simpleMock'; @@ -41,8 +43,6 @@ import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCL import { CopilotCLIMCPHandler } from '../mcpHandler'; import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers'; import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers'; -import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService'; -import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; // Re-export for backward compatibility with other spec files export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers'; @@ -108,7 +108,7 @@ describe('CopilotCLISessionService', () => { beforeEach(async () => { vi.useRealTimers(); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })), + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })), getRequestId: vi.fn(() => undefined), } as unknown as ICopilotCLISDK; @@ -341,7 +341,7 @@ describe('CopilotCLISessionService', () => { const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })) + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })) } as unknown as ICopilotCLISDK; const services = createExtensionUnitTestingServices(); disposables.add(services); @@ -380,7 +380,7 @@ describe('CopilotCLISessionService', () => { const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })) + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })) } as unknown as ICopilotCLISDK; const services = createExtensionUnitTestingServices(); disposables.add(services); @@ -445,7 +445,7 @@ describe('CopilotCLISessionService', () => { const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })) + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })) } as unknown as ICopilotCLISDK; const services = createExtensionUnitTestingServices(); disposables.add(services); @@ -491,7 +491,7 @@ describe('CopilotCLISessionService', () => { const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })) + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })) } as unknown as ICopilotCLISDK; const services = createExtensionUnitTestingServices(); disposables.add(services); @@ -755,7 +755,7 @@ describe('CopilotCLISessionService', () => { const storeMetadataSpy = vi.spyOn(metadataStore, 'storeForkedSessionMetadata'); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })), + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })), getRequestId: vi.fn(() => undefined), } as unknown as ICopilotCLISDK; const services = disposables.add(createExtensionUnitTestingServices()); @@ -796,7 +796,7 @@ describe('CopilotCLISessionService', () => { manager.sessions.set(sourceId, sdkSession); const sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })), + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })), getRequestId: vi.fn(() => ({ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1' })), } as unknown as ICopilotCLISDK; const services = disposables.add(createExtensionUnitTestingServices()); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 0279cc044e156..e190394cec3e1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -327,7 +327,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } }); sdk = { - getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })), + getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })), getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'valid-token', host: 'https://github.com' })), } as unknown as ICopilotCLISDK; const services = disposables.add(createExtensionUnitTestingServices()); From e19e28776c61aa0f794d7116058411eace0c964f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:46:49 +0000 Subject: [PATCH 13/23] Agents - get agent feedback working in the multi-file diff editor (#311686) --- .../browser/agentFeedbackEditorActions.ts | 26 +++++++++++-------- .../browser/agentFeedbackEditorUtils.ts | 13 ++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index cf0cc288886fe..44845f688be43 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -24,6 +24,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { getSessionEditorComments } from './sessionEditorComments.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; @@ -208,6 +209,7 @@ class SubmitActiveSessionFeedbackAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionManagementService = accessor.get(ISessionsManagementService); + const configurationService = accessor.get(IConfigurationService); const agentFeedbackService = accessor.get(IAgentFeedbackService); const chatWidgetService = accessor.get(IChatWidgetService); const editorService = accessor.get(IEditorService); @@ -231,18 +233,20 @@ class SubmitActiveSessionFeedbackAction extends Action2 { } // Close all editors belonging to the session resource - const editorsToClose: IEditorIdentifier[] = []; - for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { - const candidates = getActiveResourceCandidates(editor); - const belongsToSession = candidates.some(uri => - isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) - ); - if (belongsToSession) { - editorsToClose.push({ editor, groupId }); + if (configurationService.getValue('workbench.editor.useModal') === 'all') { + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); } - } - if (editorsToClose.length) { - await editorService.closeEditors(editorsToClose); } await widget.acceptInput('/act-on-feedback'); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts index c3e6cca371f00..0e7034a588ce8 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts @@ -14,6 +14,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; /** * Find the session that contains the given resource by checking editing sessions, @@ -315,6 +316,18 @@ function renderHunkGroup( export function getActiveResourceCandidates(input: Parameters[0]): URI[] { const result: URI[] = []; + + if (input instanceof MultiDiffEditorInput) { + const items = input.resources.get(); + if (items) { + for (const item of items) { + if (item.originalUri) { result.push(item.originalUri); } + if (item.modifiedUri) { result.push(item.modifiedUri); } + } + } + return result; + } + const resources = EditorResourceAccessor.getOriginalUri(input, { supportSideBySide: SideBySideEditor.BOTH }); if (!resources) { return result; From c4634a7e5fc1d3608d710ec736e308eaa777f248 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 21 Apr 2026 10:47:34 -0400 Subject: [PATCH 14/23] add expand action for question part (#311439) fixes #310886 From 7d8f4acc24e87413ba38455a0e1b91ccbe107f3a Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Tue, 21 Apr 2026 17:01:25 +0200 Subject: [PATCH 15/23] refactor: consolidate debug service registrations into a new contribution file (#311678) Co-authored-by: Copilot --- src/vs/sessions/sessions.common.main.ts | 9 ++------- .../contrib/debug/browser/debug.contribution.ts | 11 +++++------ .../debug/browser/debug.service.contribution.ts | 12 ++++++++++++ 3 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 src/vs/workbench/contrib/debug/browser/debug.service.contribution.ts diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index fa93ea472e090..d4a45aeae6795 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -264,13 +264,8 @@ import '../workbench/contrib/git/browser/git.contributions.js'; // SCM import '../workbench/contrib/scm/browser/scm.contribution.js'; -// Debug -import '../workbench/contrib/debug/browser/debug.contribution.js'; -import '../workbench/contrib/debug/browser/debugEditorContribution.js'; -import '../workbench/contrib/debug/browser/breakpointEditorContribution.js'; -import '../workbench/contrib/debug/browser/callStackEditorContribution.js'; -import '../workbench/contrib/debug/browser/repl.js'; -import '../workbench/contrib/debug/browser/debugViewlet.js'; +// Debug (service) +import '../workbench/contrib/debug/browser/debug.service.contribution.js'; // Markers import '../workbench/contrib/markers/browser/markers.contribution.js'; diff --git a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts index 866c7bc8d3380..6a10c81e6bee4 100644 --- a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts @@ -15,7 +15,6 @@ import { MenuId, MenuRegistry } from '../../../../platform/actions/common/action import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from '../../../../platform/quickinput/common/quickAccess.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -28,11 +27,10 @@ import { IViewContainersRegistry, IViewsRegistry, ViewContainer, ViewContainerLo import { launchSchemaId } from '../../../services/configuration/common/configuration.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { COPY_NOTEBOOK_VARIABLE_VALUE_ID, COPY_NOTEBOOK_VARIABLE_VALUE_LABEL } from '../../notebook/browser/contrib/notebookVariables/notebookVariableCommands.js'; -import { BREAKPOINTS_VIEW_ID, BREAKPOINT_EDITOR_CONTRIBUTION_ID, CALLSTACK_VIEW_ID, CONTEXT_BREAKPOINTS_EXIST, CONTEXT_BREAK_WHEN_VALUE_CHANGES_SUPPORTED, CONTEXT_BREAK_WHEN_VALUE_IS_ACCESSED_SUPPORTED, CONTEXT_BREAK_WHEN_VALUE_IS_READ_SUPPORTED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CAN_VIEW_MEMORY, CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_DEBUG_STATE, CONTEXT_DEBUG_UX, CONTEXT_EXPRESSION_SELECTED, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_HAS_DEBUGGED, CONTEXT_IN_DEBUG_MODE, CONTEXT_JUMP_TO_CURSOR_SUPPORTED, CONTEXT_LOADED_SCRIPTS_SUPPORTED, CONTEXT_RESTART_FRAME_SUPPORTED, CONTEXT_SET_EXPRESSION_SUPPORTED, CONTEXT_SET_VARIABLE_SUPPORTED, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_THREADS_SUPPORTED, CONTEXT_VARIABLE_EVALUATE_NAME_PRESENT, CONTEXT_VARIABLE_IS_READONLY, CONTEXT_VARIABLE_VALUE, CONTEXT_WATCH_ITEM_TYPE, DEBUG_PANEL_ID, DISASSEMBLY_VIEW_ID, EDITOR_CONTRIBUTION_ID, IDebugService, INTERNAL_CONSOLE_OPTIONS_SCHEMA, LOADED_SCRIPTS_VIEW_ID, REPL_VIEW_ID, State, VARIABLES_VIEW_ID, VIEWLET_ID, WATCH_VIEW_ID, getStateLabel } from '../common/debug.js'; +import { BREAKPOINTS_VIEW_ID, BREAKPOINT_EDITOR_CONTRIBUTION_ID, CALLSTACK_VIEW_ID, CONTEXT_BREAKPOINTS_EXIST, CONTEXT_BREAK_WHEN_VALUE_CHANGES_SUPPORTED, CONTEXT_BREAK_WHEN_VALUE_IS_ACCESSED_SUPPORTED, CONTEXT_BREAK_WHEN_VALUE_IS_READ_SUPPORTED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CAN_VIEW_MEMORY, CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_DEBUG_STATE, CONTEXT_DEBUG_UX, CONTEXT_EXPRESSION_SELECTED, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_HAS_DEBUGGED, CONTEXT_IN_DEBUG_MODE, CONTEXT_JUMP_TO_CURSOR_SUPPORTED, CONTEXT_LOADED_SCRIPTS_SUPPORTED, CONTEXT_RESTART_FRAME_SUPPORTED, CONTEXT_SET_EXPRESSION_SUPPORTED, CONTEXT_SET_VARIABLE_SUPPORTED, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_THREADS_SUPPORTED, CONTEXT_VARIABLE_EVALUATE_NAME_PRESENT, CONTEXT_VARIABLE_IS_READONLY, CONTEXT_VARIABLE_VALUE, CONTEXT_WATCH_ITEM_TYPE, DEBUG_PANEL_ID, DISASSEMBLY_VIEW_ID, EDITOR_CONTRIBUTION_ID, INTERNAL_CONSOLE_OPTIONS_SCHEMA, LOADED_SCRIPTS_VIEW_ID, REPL_VIEW_ID, State, VARIABLES_VIEW_ID, VIEWLET_ID, WATCH_VIEW_ID, getStateLabel } from '../common/debug.js'; import { DebugWatchAccessibilityAnnouncer } from '../common/debugAccessibilityAnnouncer.js'; import { DebugContentProvider } from '../common/debugContentProvider.js'; import { DebugLifecycle } from '../common/debugLifecycle.js'; -import { DebugVisualizerService, IDebugVisualizerService } from '../common/debugVisualizers.js'; import { DisassemblyViewInput } from '../common/disassemblyViewInput.js'; import { ReplAccessibilityAnnouncer } from '../common/replAccessibilityAnnouncer.js'; import { BreakpointEditorContribution } from './breakpointEditorContribution.js'; @@ -47,7 +45,6 @@ import { DebugEditorContribution } from './debugEditorContribution.js'; import * as icons from './debugIcons.js'; import { DebugProgressContribution } from './debugProgress.js'; import { StartDebugQuickAccessProvider } from './debugQuickAccess.js'; -import { DebugService } from './debugService.js'; import './debugSettingMigration.js'; import { DebugStatusContribution } from './debugStatus.js'; import { DebugTitleContribution } from './debugTitle.js'; @@ -67,10 +64,12 @@ import { ADD_WATCH_ID, ADD_WATCH_LABEL, REMOVE_WATCH_EXPRESSIONS_COMMAND_ID, REM import { WelcomeView } from './welcomeView.js'; import { DebugChatContextContribution } from './debugChatIntegration.js'; +// Register services +import './debug.service.contribution.js'; + const debugCategory = nls.localize('debugCategory', "Debug"); registerColors(); -registerSingleton(IDebugService, DebugService, InstantiationType.Delayed); -registerSingleton(IDebugVisualizerService, DebugVisualizerService, InstantiationType.Delayed); + // Register Debug Workbench Contributions Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DebugStatusContribution, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/debug/browser/debug.service.contribution.ts b/src/vs/workbench/contrib/debug/browser/debug.service.contribution.ts new file mode 100644 index 0000000000000..93038daa11fc4 --- /dev/null +++ b/src/vs/workbench/contrib/debug/browser/debug.service.contribution.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { DebugService } from './debugService.js'; +import { IDebugService } from '../common/debug.js'; +import { IDebugVisualizerService, DebugVisualizerService } from '../common/debugVisualizers.js'; + +registerSingleton(IDebugService, DebugService, InstantiationType.Delayed); +registerSingleton(IDebugVisualizerService, DebugVisualizerService, InstantiationType.Delayed); From d4e3ed3296a87508625405d0dbccc52f5ed93542 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:01:59 +0000 Subject: [PATCH 16/23] Agents - add layout actions to open in modal editor (#311680) * Agents - add action to open editor in modal editor group * Add the other action * Simplify action * Update src/vs/sessions/browser/parts/media/editorPart.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Pull request feedback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/parts/media/editorPart.css | 6 + .../contrib/changes/browser/changesView.ts | 7 +- .../editor/browser/editor.contribution.ts | 129 +++++++++++++++++- .../browser/parts/editor/editorCommands.ts | 4 +- 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index 841b364322f49..feb1ff1b7cf2f 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -63,3 +63,9 @@ /* When multiple tab bars are visible, only show editor actions for the last tab bar */ display: none; } + +/* Modal Editor Part */ + +.agent-sessions-workbench .part.editor.modal-editor-part .modal-editor-action-container .action-label.codicon.codicon-open-in-window { + transform: rotate(180deg); +} diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 60b1b833a5ade..03c4757eefb40 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -943,7 +943,7 @@ export class ChangesViewPane extends ViewPane { )); } - async openChanges(): Promise { + async openChanges(resource?: URI): Promise { const items = this.viewModel.activeSessionChangesObs.get(); if (items.length === 0) { return; @@ -952,12 +952,13 @@ export class ChangesViewPane extends ViewPane { const modalEditorMode = this.configurationService.getValue('workbench.editor.useModal'); if (modalEditorMode === 'all') { const changes = toIChangesFileItem(items); - await this._openFileItem(changes[0], changes, false, false, false, changes.length > 1); + const changeToOpen = resource ? changes.find(c => isEqual(c.uri, resource)) : undefined; + await this._openFileItem(changeToOpen ?? changes[0], changes, false, false, false, changes.length > 1); return; } // Open multi-file diff editor - await this._openMultiFileDiffEditor(); + await this._openMultiFileDiffEditor(resource); } private async _openFileItem(item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean): Promise { diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index f8f9928377b6c..0d6e2ab4bd97f 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -9,9 +9,18 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorPartModalContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { EditorMaximizedContext } from '../../../common/contextkeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { CHANGES_VIEW_ID } from '../../changes/common/changes.js'; +import { ChangesViewPane } from '../../changes/browser/changesView.js'; +import { prepareMoveCopyEditors } from '../../../../workbench/browser/parts/editor/editor.js'; +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID } from '../../../../workbench/browser/parts/editor/editorCommands.js'; class MaximizeMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; @@ -25,7 +34,7 @@ class MaximizeMainEditorPartAction extends Action2 { menu: { id: MenuId.EditorTitleLayout, group: 'navigation', - order: 1, + order: 99, when: ContextKeyExpr.and( IsSessionsWindowContext, EditorMaximizedContext.negate()) @@ -53,7 +62,7 @@ class RestoreMainEditorPartAction extends Action2 { menu: { id: MenuId.EditorTitleLayout, group: 'navigation', - order: 1, + order: 99, when: ContextKeyExpr.and( IsSessionsWindowContext, EditorMaximizedContext) @@ -94,3 +103,117 @@ class CloseMainEditorPartAction extends Action2 { } registerAction2(CloseMainEditorPartAction); + +class OpenEditorInModalEditorAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.openEditorInModal'; + + constructor() { + super({ + id: OpenEditorInModalEditorAction.ID, + title: localize2('openEditorInModal', "Open in Modal Editor"), + icon: Codicon.openInWindow, + f1: false, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 1, + when: IsSessionsWindowContext + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const configurationService = accessor.get(IConfigurationService); + const editorGroupsService = accessor.get(IEditorGroupsService); + + // Set the `workbench.editor.useModal` setting to 'all' + await configurationService.updateValue('workbench.editor.useModal', 'all'); + + // Move all editors from the active group to the modal editor + const activeGroup = editorGroupsService.mainPart.activeGroup; + + // Check for multi-file diff editor + const multiFileDiffEditor = activeGroup.editors + .find(editor => editor instanceof MultiDiffEditorInput); + + if (multiFileDiffEditor) { + // Reopen multi-file diff editor as the first editor in the modal editor + const view = viewsService.getViewWithId(CHANGES_VIEW_ID); + await view?.openChanges(); + + // Close the multi-file diff editor + await activeGroup.closeEditor(multiFileDiffEditor); + } + + // Move all remaining editors to the modal editor + const modalPart = await editorGroupsService.createModalEditorPart(); + const editorsToMove = prepareMoveCopyEditors(activeGroup, activeGroup.editors.slice(), true); + activeGroup.moveEditors(editorsToMove, modalPart.activeGroup); + modalPart.activeGroup.focus(); + } +} + +registerAction2(OpenEditorInModalEditorAction); + +class OpenModalEditorInEditorAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.openModalEditorInEditor'; + + constructor() { + super({ + id: OpenModalEditorInEditorAction.ID, + title: localize2('openModalEditorInEditor', "Open in Editor"), + icon: Codicon.openInWindow, + f1: false, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 98, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + EditorPartModalContext) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const commandService = accessor.get(ICommandService); + const configurationService = accessor.get(IConfigurationService); + const editorGroupsService = accessor.get(IEditorGroupsService); + const layoutService = accessor.get(IAgentWorkbenchLayoutService); + + const activeGroup = editorGroupsService.activeModalEditorPart?.activeGroup; + if (!activeGroup) { + return; + } + + // Set the `workbench.editor.useModal` setting back to 'some' + await configurationService.updateValue('workbench.editor.useModal', 'some'); + + // Show the main editor part + layoutService.setPartHidden(false, Parts.EDITOR_PART); + + // Check for navigation in the modal editor + const navigation = activeGroup.activeEditorPane?.options?.modal?.navigation; + if (navigation) { + const view = viewsService.getViewWithId(CHANGES_VIEW_ID); + const changes = view?.viewModel.activeSessionChangesObs.get(); + + if (changes && navigation.current < changes.length) { + // Reopen multi-file diff editor for the current file + await view?.openChanges(changes[navigation.current].modifiedUri ?? changes[navigation.current].originalUri); + + // Close the editor in the modal editor (assume that the + // multi-file diff editor is the first editor in the modal + // editor) + await activeGroup.closeEditor(activeGroup.editors[0]); + } + } + + // Move all remaining editors to the main editor part + await commandService.executeCommand(MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID); + } +} + +registerAction2(OpenModalEditorInEditorAction); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index ef66fad7e3bc7..7c8fdbf44beaa 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -1515,7 +1515,7 @@ function registerModalEditorCommands(): void { menu: { id: MenuId.ModalEditorTitle, group: 'navigation', - order: 1 + order: 99 } }); } @@ -1552,7 +1552,7 @@ function registerModalEditorCommands(): void { menu: { id: MenuId.ModalEditorTitle, group: 'navigation', - order: 2 + order: 100 } }); } From 0f7e0b9166c4b547d347a97cfa95231171fc6c18 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 21 Apr 2026 16:02:12 +0100 Subject: [PATCH 17/23] Agents: Add experimental gradient styling for chat send button (#311685) * Agents - add experimental gradient styling for chat send button * Agents - enhance chat send button focus and hover styles for gradient feature * Agents - adjust scale transformation for chat animation effect * style: add gradient animations for chat send button * feat: add experimental gradient styling for chat send button Co-authored-by: Copilot * fix: update experimental gradient setting for chat send button and refine hover styles * fix: update class name for experimental send button gradient in chat styles --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../lib/stylelint/vscode-known-variables.json | 1 + src/vs/sessions/browser/workbench.ts | 16 +- src/vs/sessions/common/configuration.ts | 1 + .../contrib/chat/browser/media/chatInput.css | 266 +++++++++++++++++- .../browser/configuration.contribution.ts | 9 +- 5 files changed, 288 insertions(+), 5 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 5ecb1b22db968..a46bef0bbab71 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1041,6 +1041,7 @@ "--animation-angle", "--animation-opacity", "--chat-input-anim-angle", + "--chat-send-button-anim-angle", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", "--vscode-chat-font-size-body-l", diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 437671338da63..6306bae337ba8 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -61,7 +61,7 @@ import { IMarkdownRendererService } from '../../platform/markdown/browser/markdo import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; import { TitleService } from './parts/titlebarPart.js'; -import { SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; +import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; import { EditorMaximizedContext } from '../common/contextkeys.js'; @@ -86,6 +86,7 @@ enum LayoutClasses { CHATBAR_HIDDEN = 'nochatbar', STATUSBAR_HIDDEN = 'nostatusbar', EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', + EXPERIMENTAL_SEND_BUTTON_GRADIENT = 'sessions-experimental-send-button-gradient', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized' } @@ -441,6 +442,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Configuration changes this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); this._register(configurationService.onDidChangeConfiguration(e => this.updateShellGradientBackground(e, configurationService))); + this._register(configurationService.onDidChangeConfiguration(e => this.updateSendButtonGradient(e, configurationService))); // Font Info if (isNative) { @@ -535,6 +537,17 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ); } + private updateSendButtonGradient(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService): void { + if (e && !e.affectsConfiguration(SessionsExperimentalSendButtonGradientSettingId)) { + return; + } + + this.mainContainer.classList.toggle( + LayoutClasses.EXPERIMENTAL_SEND_BUTTON_GRADIENT, + configurationService.getValue(SessionsExperimentalSendButtonGradientSettingId) + ); + } + //#endregion private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void { @@ -559,6 +572,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Apply font aliasing this.updateFontAliasing(undefined, configurationService); this.updateShellGradientBackground(undefined, configurationService); + this.updateSendButtonGradient(undefined, configurationService); // Warm up font cache information before building up too many dom elements this.restoreFontInfo(storageService, configurationService); diff --git a/src/vs/sessions/common/configuration.ts b/src/vs/sessions/common/configuration.ts index 16949ea8c9c0d..920a0f37133c8 100644 --- a/src/vs/sessions/common/configuration.ts +++ b/src/vs/sessions/common/configuration.ts @@ -4,3 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export const SessionsExperimentalShellGradientBackgroundSettingId = 'sessions.experimental.shellGradientBackground'; +export const SessionsExperimentalSendButtonGradientSettingId = 'sessions.experimental.sendButtonGradient'; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 60489450b87ec..8a31c06df2cc1 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -185,11 +185,65 @@ gap: 4px; } +/* Delightful gradient styling for the chat send (submit) button. The button + gets a multi-color gradient ring at rest using the same palette as the + working-state border, fills with a slowly cycling color on hover/focus, + and emits a quick colorful pulse on click. Gated behind the experimental + `sessions.experimental.sendButtonGradient` setting via the + `.sessions-experimental-send-button-gradient` class on the workbench root. */ +@property --chat-send-button-anim-angle { + syntax: ''; + inherits: false; + initial-value: 135deg; +} + +@keyframes chat-send-button-spin { + from { + --chat-send-button-anim-angle: 135deg; + } + + to { + --chat-send-button-anim-angle: 495deg; + } +} + +@keyframes chat-send-button-color-cycle { + 0%, + 100% { + background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)); + } + + 33% { + background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 60%, var(--vscode-input-background)); + } + + 66% { + background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 60%, var(--vscode-input-background)); + } +} + +@keyframes chat-send-button-pulse { + 0% { + opacity: 0.7; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(1.3); + } +} + /* Send button - wraps a Button widget */ .sessions-chat-send-button { display: flex; align-items: center; + justify-content: center; flex-shrink: 0; + position: relative; + width: 22px; + height: 22px; + border-radius: 4px; } .sessions-chat-send-button .monaco-button { @@ -202,23 +256,229 @@ padding: 0; border-radius: 4px; color: var(--vscode-icon-foreground); - background: transparent !important; - border: none !important; + background: transparent; + border: none; cursor: pointer; + position: relative; + z-index: 1; + transition: background-color 250ms ease, color 250ms ease; } .sessions-chat-send-button .monaco-button.disabled { cursor: default; } +/* Default hover feedback when the gradient experiment is off. The gradient-on + rules below override this with the cycling color treatment. */ .sessions-chat-send-button .monaco-button:not(.disabled):hover { - background-color: var(--vscode-toolbar-hoverBackground) !important; + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Focus indicator drawn on the wrapper so it sits cleanly around the + 22x22 button surface (the inner Button widget doesn't draw its own + focus border). Works in both gradient-on and gradient-off states. */ +.sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +/* Suppress the inner button's default focus outline so it doesn't double up + with the wrapper outline above. */ +.sessions-chat-send-button .monaco-button:focus, +.sessions-chat-send-button .monaco-button:focus-visible { + outline: none !important; + box-shadow: none !important; } .monaco-workbench .sessions-chat-send-button .monaco-button .codicon[class*='codicon-'] { font-size: 14px; } +/* Ensure no underline / link decoration ever shows under the codicon glyph + (the button is rendered as an tag). */ +.monaco-workbench .sessions-chat-send-button a.monaco-button, +.monaco-workbench .sessions-chat-send-button a.monaco-button:hover, +.monaco-workbench .sessions-chat-send-button a.monaco-button:focus, +.monaco-workbench .sessions-chat-send-button a.monaco-button:active, +.monaco-workbench .sessions-chat-send-button a.monaco-button.disabled { + text-decoration: none !important; +} + +/* Gradient ring at rest, drawn on the wrapper so the button's own + ::before (codicon glyph) is not overridden. Gated behind the experimental + `sessions.experimental.sendButtonGradient` setting via the + `.sessions-experimental-send-button-gradient` class on the workbench root. */ +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 4px; + padding: 1px; + background: conic-gradient(from var(--chat-send-button-anim-angle), + var(--vscode-chat-inputWorkingBorderColor1), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor3), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor1)); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + transition: opacity 250ms ease; + z-index: 2; + /* Idle: very slowly rotate the gradient ring. */ + animation: chat-send-button-spin 18s linear infinite; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button.disabled)::before { + display: none; +} + +/* Hover/focus: fill the button with a solid color that smoothly cycles through + the gradient palette, rather than spinning the conic gradient. Focus mirrors + hover so keyboard users get the same delightful treatment. */ +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button, +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button { + background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)); + animation: chat-send-button-color-cycle 4.5s ease-in-out infinite; + color: var(--vscode-button-foreground); +} + +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover)::before, +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible)::before { + opacity: 0; +} + +/* Click: outward color pulse on the wrapper. */ +.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 6px; + background: conic-gradient(from 135deg, + var(--vscode-chat-inputWorkingBorderColor1), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor3), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor1)); + pointer-events: none; + animation: chat-send-button-pulse 400ms ease-out forwards; + z-index: 0; +} + +/* ---------------------------------------------------------------------------- + Gradient styling for the standard chat-input send button (the one rendered + inside session views by the shared ChatInputPart). Mirrors the wrapper + rules above but targets the toolbar action-item that hosts the arrow-up + codicon. Gated by the same `.sessions-experimental-send-button-gradient` class on + the sessions workbench root so only Sessions/Agents UI is affected. + ---------------------------------------------------------------------------- */ +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) { + position: relative; + border-radius: 5px; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up { + transition: background-color 250ms ease, color 250ms ease; +} + +/* Focus indicator drawn on the action-item wrapper so it sits cleanly around + the button surface with a small offset, matching the new-session button. */ +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + border-radius: 5px; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus, +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus-visible { + outline: none; +} + +/* Gradient ring at rest, drawn with the standard mask trick so only the + 1px border area is painted (icon stays transparent over toolbar). */ +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up)::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: conic-gradient(from var(--chat-send-button-anim-angle), + var(--vscode-chat-inputWorkingBorderColor1), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor3), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor1)); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + transition: opacity 250ms ease; + z-index: 1; + animation: chat-send-button-spin 18s linear infinite; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.disabled:has(> .action-label.codicon-arrow-up)::before { + display: none; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible { + background-color: color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)); + animation: chat-send-button-color-cycle 4.5s ease-in-out infinite; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:hover)::before, +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible)::before { + opacity: 0; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:hover) .codicon-arrow-up, +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) .codicon-arrow-up { + color: var(--vscode-button-foreground) !important; +} + +.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 7px; + background: conic-gradient(from 135deg, + var(--vscode-chat-inputWorkingBorderColor1), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor3), + var(--vscode-chat-inputWorkingBorderColor2), + var(--vscode-chat-inputWorkingBorderColor1)); + pointer-events: none; + animation: chat-send-button-pulse 400ms ease-out forwards; + z-index: 0; +} + +@media (prefers-reduced-motion: reduce) { + /* New-session send button */ + .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button::before, + .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button, + .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button, + .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after, + /* Existing-chat toolbar send button */ + .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up)::before, + .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, + .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible, + .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after { + animation: none; + } +} + /* Loading spinner in toolbar */ .sessions-chat-loading-spinner { display: none; diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 3f1dc4f583187..a441cc2871b6e 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -6,7 +6,7 @@ import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../nls.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { SessionsExperimentalShellGradientBackgroundSettingId } from '../../../common/configuration.js'; +import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../../../common/configuration.js'; import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; Registry.as(Extensions.Configuration).registerConfiguration({ @@ -19,6 +19,13 @@ Registry.as(Extensions.Configuration).registerConfigurat tags: ['experimental'], description: localize('sessions.experimental.shellGradientBackground', "Whether to enable the experimental accent-tinted shell background in the Sessions window."), }, + [SessionsExperimentalSendButtonGradientSettingId]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + description: localize('sessions.experimental.sendButtonGradient', "Whether to show a colorful animated gradient on the chat send button in the Sessions window. The button shows a slowly rotating gradient ring at rest, fills with a cycling color on hover, and emits a color pulse on click."), + }, }, }); From 3170842edd4e822b8e83a73a24eb149dd6d99494 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 21 Apr 2026 20:07:34 +0500 Subject: [PATCH 18/23] Fix steer/queue keybinding labels in picker dropdown (#310961) * Fix steer/queue keybinding labels in picker dropdown The queue/steer picker dropdown was showing inverted keybinding labels: e.g. Steer showed 'Alt+Enter' but Enter actually steered. Root cause: ActionWidgetDropdown looked up keybindings via the global IKeybindingService context. The queue/steer keybindings' when-clauses require inChatInput and requestInProgress, which are scoped context keys not visible globally. lookupPrimaryKeybinding fell back to the last registered binding for each the opposite of what was active.command Fix: - Add optional keybinding field to IActionWidgetDropdownAction so callers can override the global lookup with a pre-resolved keybinding. - In ChatQueuePickerActionItem, resolve Enter / Alt+Enter directly and assign them to queue/steer based on the user's configured default, so labels always match what is actually bound. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use scoped context for keybinding lookup Address PR review: resolve queue/steer dropdown keybinding labels via the scoped contextKeyService instead of hard-coding Enter/Alt+Enter. This respects user customizations and scoped overrides (e.g. editingRequestType when editing a queued/steer request). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add chatInputHasText to lookup overlay; add unit test The keybinding when clauses also require chatInputHasText. Without including it in the overlay used to look up display labels, the picker silently fell back to no label. Add it (the picker is only shown when there is text to send) and add a unit test that asserts the resolved keybinding for both default=steer and default=queue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/actionWidgetDropdown.ts | 22 ++++-- .../widget/input/chatQueuePickerActionItem.ts | 34 +++++++-- .../browser/actions/chatQueueActions.test.ts | 74 +++++++++++++++++++ 3 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/actions/chatQueueActions.test.ts diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 82e6ff581bad9..87fd04735db76 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IActionWidgetService } from './actionWidget.js'; -import { IAction } from '../../../base/common/actions.js'; +import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; +import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js'; +import { IAction } from '../../../base/common/actions.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; +import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; +import { IActionWidgetService } from './actionWidget.js'; export interface IActionWidgetDropdownAction extends IAction { category?: { label: string; order: number; showHeader?: boolean }; @@ -26,6 +27,13 @@ export interface IActionWidgetDropdownAction extends IAction { * Optional toolbar actions shown when the item is focused or hovered. */ toolbarActions?: IAction[]; + /** + * Optional keybinding to display next to the action. When provided, this overrides the + * keybinding that would otherwise be looked up via {@link IKeybindingService.lookupKeybinding}. + * Useful when the active keybinding depends on a scoped context (e.g. focus state) that the + * dropdown cannot evaluate at display time. + */ + keybinding?: ResolvedKeybinding; } // TODO @lramos15 - Should we just make IActionProvider templated? @@ -139,7 +147,7 @@ export class ActionWidgetDropdown extends BaseDropdown { hideIcon: false, label: action.label, keybinding: this._options.showItemKeybindings ? - this.keybindingService.lookupKeybinding(action.id) : + (action.keybinding ?? this.keybindingService.lookupKeybinding(action.id)) : undefined, }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index d9beb3af8bb52..52f140bfee005 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -50,8 +50,8 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IActionWidgetService actionWidgetService: IActionWidgetService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @ITelemetryService telemetryService: ITelemetryService, ) { super(undefined, action); @@ -63,13 +63,13 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { 'chat.queuePickerPrimary', isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"), ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowUp : Codicon.add), - !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), + !!this.contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), () => this._runDefaultAction() )); this._primaryAction = this._register(new ActionViewItem(undefined, this._primaryActionAction, { icon: true, label: false })); - this._register(contextKeyService.onDidChangeContext(e => { - this._primaryActionAction.enabled = !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key); + this._register(this.contextKeyService.onDidChangeContext(e => { + this._primaryActionAction.enabled = !!this.contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key); })); // Dropdown - action widget with hover descriptions and chevron-down icon @@ -81,8 +81,8 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { showItemKeybindings: true, }, actionWidgetService, - keybindingService, - contextKeyService, + this.keybindingService, + this.contextKeyService, telemetryService, )); @@ -176,6 +176,24 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { private _getDropdownActions(): IActionWidgetDropdownAction[] { const isSteerDefault = this._isSteerDefault(); + // Resolve display keybindings against an overlay context that simulates the chat input + // being focused with a request in progress. The injected `contextKeyService` may be the + // chat widget's outer scope (which has `requestInProgress` but lacks `inChatInput`) or + // even the global scope; either way, the queue/steer keybindings' `when` clauses would + // not match and the resolver would fall back to the last-registered binding for each + // command, producing labels that contradict actual behavior. Overlaying the scope keys + // the bindings expect ensures we look up the binding that would actually fire. + // Other context keys (e.g. `editingRequestType`, `config.chat.requestQueuing.defaultAction`) + // are read from the parent context, so user customizations and scoped overrides like + // editing a queued/steer request are still respected. + const lookupContext = this.contextKeyService.createOverlay([ + [ChatContextKeys.inputHasText.key, true], + [ChatContextKeys.inChatInput.key, true], + [ChatContextKeys.requestInProgress.key, true], + ]); + const queueKeybinding = this.keybindingService.lookupKeybinding(ChatQueueMessageAction.ID, lookupContext, true); + const steerKeybinding = this.keybindingService.lookupKeybinding(ChatSteerWithMessageAction.ID, lookupContext, true); + const queueAction: IActionWidgetDropdownAction = { id: ChatQueueMessageAction.ID, label: localize('chat.queueMessage', "Add to Queue"), @@ -184,6 +202,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { checked: !isSteerDefault, icon: Codicon.add, class: undefined, + keybinding: queueKeybinding, hover: { content: localize('chat.queueMessage.hover', "Queue this message to send after the current request completes. The current response will finish uninterrupted before the queued message is sent."), }, @@ -200,6 +219,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { checked: isSteerDefault, icon: Codicon.arrowUp, class: undefined, + keybinding: steerKeybinding, hover: { content: localize('chat.steerWithMessage.hover', "Send this message at the next opportunity, signaling the current request to yield. The current response will stop and the new message will be sent immediately."), }, diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatQueueActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatQueueActions.test.ts new file mode 100644 index 0000000000000..c00f652588fe4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatQueueActions.test.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { OS } from '../../../../../../base/common/platform.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { KeybindingsRegistry } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeybindingResolver } from '../../../../../../platform/keybinding/common/keybindingResolver.js'; +import { ResolvedKeybindingItem } from '../../../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { USLayoutResolvedKeybinding } from '../../../../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; +import { ChatQueueMessageAction, ChatSteerWithMessageAction, registerChatQueueActions } from '../../../browser/actions/chatQueueActions.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../common/constants.js'; + +// Register actions once so the keybindings appear in KeybindingsRegistry. +registerChatQueueActions(); + +suite('Queue/Steer keybinding resolution', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function buildResolverForCommands(commandIds: string[]): KeybindingResolver { + const items: ResolvedKeybindingItem[] = []; + for (const item of KeybindingsRegistry.getDefaultKeybindingsForOS(OS)) { + if (!item.command || !commandIds.includes(item.command) || !item.keybinding) { + continue; + } + const resolved = USLayoutResolvedKeybinding.resolveKeybinding(item.keybinding, OS)[0]; + items.push(new ResolvedKeybindingItem(resolved, item.command, item.commandArgs, item.when ?? undefined, true, null, false)); + } + return new KeybindingResolver(items, [], () => { }); + } + + function lookupForConfig(defaultAction: 'steer' | 'queue') { + const config = new TestConfigurationService({ [ChatConfiguration.RequestQueueingDefaultAction]: defaultAction }); + const ctxService = new ContextKeyService(config); + // Simulate the chat input being focused with a request in progress, like the picker does. + const overlay = ctxService.createOverlay([ + [ChatContextKeys.inputHasText.key, true], + [ChatContextKeys.inChatInput.key, true], + [ChatContextKeys.requestInProgress.key, true], + ]); + const resolver = buildResolverForCommands([ChatQueueMessageAction.ID, ChatSteerWithMessageAction.ID]); + return { + result: { + queue: resolver.lookupPrimaryKeybinding(ChatQueueMessageAction.ID, overlay, true)?.resolvedKeybinding?.getDispatchChords()[0] ?? null, + steer: resolver.lookupPrimaryKeybinding(ChatSteerWithMessageAction.ID, overlay, true)?.resolvedKeybinding?.getDispatchChords()[0] ?? null, + }, + dispose: () => ctxService.dispose(), + }; + } + + test('with default=steer, Enter steers and Alt+Enter queues', () => { + const { result, dispose } = lookupForConfig('steer'); + try { + assert.deepStrictEqual(result, { queue: 'alt+Enter', steer: 'Enter' }); + } finally { + dispose(); + } + }); + + test('with default=queue, Enter queues and Alt+Enter steers', () => { + const { result, dispose } = lookupForConfig('queue'); + try { + assert.deepStrictEqual(result, { queue: 'Enter', steer: 'alt+Enter' }); + } finally { + dispose(); + } + }); +}); From 2749e2b49d385412cfa3a69374d6b355c1f45e9b Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 21 Apr 2026 16:23:34 +0100 Subject: [PATCH 19/23] Adjust padding in chat editor for improved layout (#311692) style: adjust padding in chat editor for improved layout Co-authored-by: mrleemurray --- src/vs/sessions/contrib/chat/browser/media/chatInput.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 8a31c06df2cc1..00868b5b63b2e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -62,7 +62,7 @@ /* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ .sessions-chat-editor { - padding: 6px 6px 0 6px; + padding: 2px 10px 0 10px; flex-shrink: 1; } From ad714f0a36508bd15f8e0ff75758005c40d0f0d0 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:43:19 +0000 Subject: [PATCH 20/23] Agents - start cleaning up IChatSessionFileChange and IChatSessionFileChange2 (#311688) * Agents - start cleaning up IChatSessionFileChange and IChatSessionFileChange2 * More cleanup * Some more cleanup * Fix hygiene --- .../browser/agentFeedbackEditorUtils.ts | 9 +++---- .../agentFeedbackEditorWidgetContribution.ts | 9 ++++--- .../browser/agentFeedbackService.ts | 11 +++----- .../changes/browser/changesTitleBarWidget.ts | 20 ++++++++++++-- .../contrib/changes/browser/changesView.ts | 6 ++--- .../changes/browser/changesViewModel.ts | 10 +++---- .../changes/browser/changesViewRenderer.ts | 6 ++--- .../codeReview/browser/codeReviewService.ts | 5 ++-- .../browser/copilotChatSessionsProvider.ts | 26 +++++++++---------- .../services/sessions/common/session.ts | 8 +++--- 10 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts index 0e7034a588ce8..fd3266f8d9388 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts @@ -12,9 +12,10 @@ import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMa import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; /** * Find the session that contains the given resource by checking editing sessions, @@ -40,14 +41,12 @@ export function getSessionForResource( return undefined; } -export type AgentFeedbackSessionChange = IChatSessionFileChange | IChatSessionFileChange2; - export interface IAgentFeedbackContext { readonly codeSelection?: string; readonly diffHunks?: string; } -export function changeMatchesResource(change: AgentFeedbackSessionChange, resourceUri: URI): boolean { +export function changeMatchesResource(change: ISessionFileChange, resourceUri: URI): boolean { if (isIChatSessionFileChange2(change)) { return change.uri.fsPath === resourceUri.fsPath || change.modifiedUri?.fsPath === resourceUri.fsPath @@ -62,7 +61,7 @@ export function getSessionChangeForResource( sessionResource: URI | undefined, resourceUri: URI, sessionsManagementService: ISessionsManagementService, -): AgentFeedbackSessionChange | undefined { +): ISessionFileChange | undefined { if (!sessionResource) { return undefined; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 3c61bfec655f2..66234c9fc6c65 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -26,7 +26,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import * as nls from '../../../../nls.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { ICodeReviewService, IPRReviewState } from '../../codeReview/browser/codeReviewService.js'; @@ -37,6 +37,7 @@ import { IMarkdownRendererService } from '../../../../platform/markdown/browser/ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; interface ICommentItemActions { editAction: Action; @@ -730,7 +731,7 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return comments.filter(comment => comment.resourceUri.fsPath === resourceUri.fsPath); } - private _getSessionChangeForResource(resourceUri: URI): IChatSessionFileChange | IChatSessionFileChange2 | undefined { + private _getSessionChangeForResource(resourceUri: URI): ISessionFileChange | undefined { if (!this._sessionResource) { return undefined; } @@ -743,7 +744,7 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return changes.find(change => this._changeMatchesFsPath(change, resourceUri)); } - private _changeMatchesFsPath(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + private _changeMatchesFsPath(change: ISessionFileChange, resourceUri: URI): boolean { if (isIChatSessionFileChange2(change)) { return change.uri.fsPath === resourceUri.fsPath || change.modifiedUri?.fsPath === resourceUri.fsPath @@ -754,7 +755,7 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito || change.originalUri?.fsPath === resourceUri.fsPath; } - private _isCurrentOrModifiedResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + private _isCurrentOrModifiedResource(change: ISessionFileChange, resourceUri: URI): boolean { if (isIChatSessionFileChange2(change)) { return isEqual(change.uri, resourceUri) || (change.modifiedUri ? isEqual(change.modifiedUri, resourceUri) : false); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 4c0b1ec7b8c98..8bafcf1c8657b 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -11,7 +11,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { generateUuid } from '../../../../base/common/uuid.js'; import { isEqual } from '../../../../base/common/resources.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; import { changeMatchesResource, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; @@ -22,6 +22,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logChangesViewReviewCommentAdded } from '../../../common/sessionsTelemetry.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; // --- Types -------------------------------------------------------------------- @@ -363,11 +364,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this.setNavigationAnchor(sessionResource, commentId); } - private _getSessionChange(resourceUri: URI, changes: readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[] | { - readonly files: number; - readonly insertions: number; - readonly deletions: number; - } | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined { + private _getSessionChange(resourceUri: URI, changes: readonly ISessionFileChange[] | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined { if (!(changes instanceof Array)) { return undefined; } @@ -392,7 +389,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe }; } - private _changeContainsResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + private _changeContainsResource(change: ISessionFileChange, resourceUri: URI): boolean { if (isIChatSessionFileChange2(change)) { return change.uri.fsPath === resourceUri.fsPath || change.originalUri?.fsPath === resourceUri.fsPath diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts index 17842699f6046..4a8c8d9a000ee 100644 --- a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -22,7 +22,6 @@ import { IInstantiationService, ServicesAccessor } from '../../../../platform/in import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IsAuxiliaryWindowContext, AuxiliaryBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; -import { getAgentChangesSummary } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; @@ -33,6 +32,7 @@ import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logChangesViewToggle } from '../../../common/sessionsTelemetry.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CHANGES_VIEW_CONTAINER_ID } from '../common/changes.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; const TOGGLE_CHANGES_VIEW_ID = 'workbench.action.agentSessions.toggleChangesView'; @@ -106,7 +106,7 @@ class ChangesTitleBarActionViewItem extends BaseActionViewItem { const activeSession = this.activeSessionService.activeSession.get(); const resource = activeSession?.resource; const session = resource ? this.activeSessionService.getSession(resource) : undefined; - const summary = session ? getAgentChangesSummary(session.changes.get()) : undefined; + const summary = session ? this._getSessionChangesSummary(session.changes.get()) : undefined; // Rebuild inner content: [diff icon] +insertions -deletions append(btn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); @@ -135,6 +135,22 @@ class ChangesTitleBarActionViewItem extends BaseActionViewItem { )); } } + + private _getSessionChangesSummary(changes: readonly ISessionFileChange[]): { + files: number; insertions: number; deletions: number; + } | undefined { + if (changes.length === 0) { + return undefined; + } + + let insertions = 0, deletions = 0; + for (const change of changes) { + insertions += change.insertions; + deletions += change.deletions; + } + + return { files: changes.length, insertions, deletions }; + } } /** diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 03c4757eefb40..4c5f2f1bfb57c 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -59,7 +59,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { CIStatusWidget } from './checksWidget.js'; -import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../services/sessions/common/session.js'; +import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange, SessionStatus } from '../../../services/sessions/common/session.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; import { Color } from '../../../../base/common/color.js'; @@ -74,7 +74,7 @@ import { ResourceTree } from '../../../../base/common/resourceTree.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; const $ = dom.$; @@ -1015,7 +1015,7 @@ export class ChangesViewPane extends ViewPane { return; } - const compare = (aChange: IChatSessionFileChange | IChatSessionFileChange2, bChange: IChatSessionFileChange | IChatSessionFileChange2): number => { + const compare = (aChange: ISessionFileChange, bChange: ISessionFileChange): number => { const aPath = isIChatSessionFileChange2(aChange) ? aChange.uri.fsPath : aChange.modifiedUri.fsPath; const bPath = isIChatSessionFileChange2(bChange) ? bChange.uri.fsPath : bChange.modifiedUri.fsPath; return comparePaths(aPath, bPath); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 5114a455fa932..d1b0e4a8715f5 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -13,9 +13,9 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { GitDiffChange, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { COPILOT_CLOUD_SESSION_TYPE } from '../../../services/sessions/common/session.js'; +import { COPILOT_CLOUD_SESSION_TYPE, ISessionFileChange } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; @@ -78,7 +78,7 @@ export interface ActiveSessionState { export class ChangesViewModel extends Disposable { readonly activeSessionResourceObs: IObservable; readonly activeSessionTypeObs: IObservable; - readonly activeSessionChangesObs: IObservable; + readonly activeSessionChangesObs: IObservable; readonly activeSessionHasGitRepositoryObs: IObservable; readonly activeSessionFirstCheckpointRefObs: IObservable; readonly activeSessionLastCheckpointRefObs: IObservable; @@ -227,7 +227,7 @@ export class ChangesViewModel extends Disposable { }); } - private _getActiveSessionChanges(): IObservable { + private _getActiveSessionChanges(): IObservable { // Changes const activeSessionChangesObs = derived(reader => { const activeSession = this.sessionManagementService.activeSession.read(reader); @@ -318,7 +318,7 @@ export class ChangesViewModel extends Disposable { }); return derivedOpts({ - equalsFn: arrayEqualsC() + equalsFn: arrayEqualsC() }, reader => { const versionMode = this.versionModeObs.read(reader); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts index 46f71be3ce78a..cc31d1a26a639 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts @@ -23,16 +23,16 @@ import { ILabelService } from '../../../../platform/label/common/label.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../../services/sessions/common/session.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange } from '../../../services/sessions/common/session.js'; import { ActiveSessionContextKeys, ChangesContextKeys, ChangesViewMode } from '../common/changes.js'; import { ChangesViewModel } from './changesViewModel.js'; const $ = dom.$; -export function toIChangesFileItem(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): IChangesFileItem[] { +export function toIChangesFileItem(changes: readonly ISessionFileChange[]): IChangesFileItem[] { return changes.map(change => { const isAddition = change.originalUri === undefined; const isDeletion = change.modifiedUri === undefined; diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts index 2a4683c5ec4f7..4f5dde853c529 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -14,9 +14,10 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { generateUuid } from '../../../../base/common/uuid.js'; import { hash } from '../../../../base/common/hash.js'; import { hasKey } from '../../../../base/common/types.js'; -import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; // --- Types ------------------------------------------------------------------- @@ -45,7 +46,7 @@ export interface ICodeReviewFile { readonly baseUri?: URI; } -export function getCodeReviewFilesFromSessionChanges(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): readonly ICodeReviewFile[] { +export function getCodeReviewFilesFromSessionChanges(changes: readonly ISessionFileChange[]): readonly ICodeReviewFile[] { return changes.map(change => { if (isIChatSessionFileChange2(change)) { return { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index c93edc1ff4999..4797b7946fc98 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -21,8 +21,8 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; -import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange } from '../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; @@ -70,7 +70,7 @@ export interface ICopilotChatSession { /** Current session status. */ readonly status: IObservable; /** File changes produced by the session. */ - readonly changes: IObservable; + readonly changes: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; /** Currently selected mode identifier and kind. */ @@ -179,8 +179,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { private readonly _loading = observableValue(this, true); readonly loading: IObservable = this._loading; - private readonly _changes: ReturnType>; - readonly changes: IObservable; + private readonly _changes: ReturnType>; + readonly changes: IObservable; private readonly _isArchived = observableValue(this, false); readonly isArchived: IObservable = this._isArchived; @@ -258,7 +258,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this._description = observableValue(this, undefined); this.description = this._description; - this._changes = observableValue(this, []); + this._changes = observableValue(this, []); this.changes = this._changes; } @@ -462,7 +462,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession private readonly _workspaceData = observableValue(this, undefined); readonly workspace: IObservable = this._workspaceData; - readonly changes: IObservable = observableValue(this, []); + readonly changes: IObservable = observableValue(this, []); private readonly _modelIdObservable = observableValue(this, undefined); readonly modelId: IObservable = this._modelIdObservable; @@ -704,7 +704,7 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { private readonly _workspaceData = observableValue(this, undefined); readonly workspace: IObservable = this._workspaceData; - readonly changes: IObservable = observableValue(this, []); + readonly changes: IObservable = observableValue(this, []); private readonly _modelIdObservable = observableValue(this, undefined); readonly modelId: IObservable = this._modelIdObservable; @@ -843,8 +843,8 @@ class AgentSessionAdapter implements ICopilotChatSession { private readonly _status: ReturnType>; readonly status: IObservable; - private readonly _changes: ReturnType>; - readonly changes: IObservable; + private readonly _changes: ReturnType>; + readonly changes: IObservable; readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -895,7 +895,7 @@ class AgentSessionAdapter implements ICopilotChatSession { this._status = observableValue(this, toSessionStatus(session.status)); this.status = this._status; - this._changes = observableValue(this, this._extractChanges(session)); + this._changes = observableValue(this, this._extractChanges(session)); this.changes = this._changes; this.modelId = observableValue(this, undefined); @@ -1093,12 +1093,12 @@ class AgentSessionAdapter implements ICopilotChatSession { return undefined; } - private _extractChanges(session: IAgentSession): readonly IChatSessionFileChange[] { + private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] { if (!session.changes) { return []; } if (Array.isArray(session.changes)) { - return session.changes as IChatSessionFileChange[]; + return session.changes as ISessionFileChange[]; } // Summary object — create a synthetic entry for total insertions/deletions const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number }; diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 8d97f27a7506c..c7627051c57c9 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -9,7 +9,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { IChatSessionFileChange } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; export interface ISessionType { /** Unique identifier (e.g., 'copilot-cli', 'copilot-cloud', 'claude-code'). */ @@ -117,6 +117,8 @@ export interface IGitHubInfo { }; } +export type ISessionFileChange = IChatSessionFileChange | IChatSessionFileChange2; + /** * A single chat within a session, produced by the sessions management layer. */ @@ -135,7 +137,7 @@ export interface IChat { /** Current chat status. */ readonly status: IObservable; /** File changes produced by the chat. */ - readonly changes: IObservable; + readonly changes: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; /** Currently selected mode identifier and kind. */ @@ -179,7 +181,7 @@ export interface ISession { /** Current session status. */ readonly status: IObservable; /** File changes produced by the session. */ - readonly changes: IObservable; + readonly changes: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; /** Currently selected mode identifier and kind. */ From b70766c3cdf2f9eaad1a7878c59dd4909b0b8634 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:47:20 -0700 Subject: [PATCH 21/23] Fix customizations sidebar restoring previous section on reopen (#311458) * Fix customizations sidebar restoring previous section on reopen When clicking a sidebar category to open the Chat Customizations editor, always reset to the welcome page instead of restoring the previously selected section. This fixes the bug where clicking a different category (e.g. Hooks) after previously viewing another (e.g. Agents) would incorrectly reopen the old section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: avoid runtime editor import in customization overview Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/bb6388b9-101e-4ded-af52-a6524bb6ed63 Co-authored-by: joshspicer <23246594+joshspicer@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../browser/aiCustomizationOverviewView.ts | 14 ++++++++++++-- .../aiCustomizationManagementEditor.ts | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts index 279c2d94a5b32..a8dac0e1c4bfd 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts @@ -22,7 +22,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ResourceSet } from '../../../../base/common/map.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationManagementSection, AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; @@ -35,6 +35,10 @@ const $ = DOM.$; export const AI_CUSTOMIZATION_OVERVIEW_VIEW_ID = 'workbench.view.aiCustomizationOverview'; +function isWelcomePageEditor(editor: unknown): editor is { showWelcomePage(): void } { + return typeof (editor as { showWelcomePage?: unknown })?.showWelcomePage === 'function'; +} + interface ISectionSummary { readonly id: AICustomizationManagementSection; readonly label: string; @@ -222,7 +226,13 @@ export class AICustomizationOverviewView extends ViewPane { private async openOverview(): Promise { const input = AICustomizationManagementEditorInput.getOrCreate(); - await this.editorService.openEditor(input, { pinned: true }); + const editor = await this.editorService.openEditor(input, { pinned: true }); + + // Always reset to the welcome page when opening from the sidebar, + // so we don't restore the previously selected section. + if (editor?.getId() === AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID && isWelcomePageEditor(editor)) { + editor.showWelcomePage(); + } } protected override layoutBody(height: number, width: number): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 87d1743b42f76..2fa8262672846 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1007,7 +1007,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Navigates to the welcome page (no section selected). */ - private showWelcomePage(): void { + public showWelcomePage(): void { if (this.viewMode === 'editor') { this.goBackToList(); } From 177f8e4a883de95c9923782754a2383d532dc1c9 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Tue, 21 Apr 2026 17:54:29 +0200 Subject: [PATCH 22/23] Surfacing the chat customization item menu (#311641) surfacing the item --- .../services/actions/common/menusExtensionPoint.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 2a2643375a2de..67c86483f14b8 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -531,6 +531,13 @@ const apiMenus: IAPIMenu[] = [ supportsSubmenus: false, proposed: 'chatSessionCustomizationProvider', }, + { + key: 'chat/customizations/item', + id: MenuId.for('AICustomizationManagementEditorItem'), + description: localize('menus.chatCustomizationsItem', "The item context menu in the Chat Customizations management editor, including inline actions."), + supportsSubmenus: false, + proposed: 'chatSessionCustomizationProvider', + }, { key: 'chat/editor/inlineGutter', id: MenuId.ChatEditorInlineMenu, From 24c9ba10d241c0fd5938ca58868c8783a7a52672 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 21 Apr 2026 21:12:02 +0500 Subject: [PATCH 23/23] update docs for edit capturing (#311694) --- .../copilot/docs/NES_EXPECTED_EDIT_CAPTURE.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/docs/NES_EXPECTED_EDIT_CAPTURE.md b/extensions/copilot/docs/NES_EXPECTED_EDIT_CAPTURE.md index 9f69e9886da4f..217c6f3932eac 100644 --- a/extensions/copilot/docs/NES_EXPECTED_EDIT_CAPTURE.md +++ b/extensions/copilot/docs/NES_EXPECTED_EDIT_CAPTURE.md @@ -16,7 +16,7 @@ Add this setting to your VS Code `settings.json`: } ``` -That's it! Auto-capture on rejection is enabled by default. To disable it (you can still capture manually via **Cmd+K Cmd+R**): +That's it! Auto-capture on rejection is enabled by default. To disable it (you can still capture manually via the Command Palette): ```json { "github.copilot.chat.advanced.inlineEdits.recordExpectedEdit.onReject": false @@ -29,14 +29,14 @@ That's it! Auto-capture on rejection is enabled by default. To disable it (you c 1. Reject the suggestion (press `Esc` or continue typing) 2. If `onReject` is enabled, capture mode starts automatically 3. Type the code you *expected* NES to suggest -4. Press **Enter** to save, or **Esc** to cancel +4. Press **Cmd+Enter** (Mac) / **Ctrl+Enter** (Windows/Linux) to save, or **Esc** to cancel **When NES didn't appear but should have:** -1. Press **Cmd+K Cmd+R** (Mac) or **Ctrl+K Ctrl+R** (Windows/Linux) +1. Open the Command Palette (**Cmd+Shift+P** / **Ctrl+Shift+P**) and run **"Copilot: Record Expected Edit (NES)"** 2. Type the code you expected NES to suggest -3. Press **Enter** to save +3. Press **Cmd+Enter** / **Ctrl+Enter** to save -> **Tip:** Use **Shift+Enter** to insert newlines during capture (since Enter saves). +> **Tip:** To indicate that *no* edit was expected (i.e. the rejection was correct), press **Cmd+Enter** / **Ctrl+Enter** without making any edits. ### 3. Submit Your Feedback Once you've captured some edits: @@ -49,10 +49,10 @@ Once you've captured some edits: | Action | Keybinding | |--------|------------| -| Start capture manually | **Cmd+K Cmd+R** / **Ctrl+K Ctrl+R** | -| Save capture | **Enter** | +| Start capture manually | Command Palette → **Copilot: Record Expected Edit (NES)** | +| Save capture | **Cmd+Enter** / **Ctrl+Enter** | +| Save as "no edit expected" | **Cmd+Enter** / **Ctrl+Enter** with no edits made | | Cancel capture | **Esc** | -| Insert newline | **Shift+Enter** | | Command | Description | |---------|-------------| @@ -63,13 +63,13 @@ Once you've captured some edits: ### Trigger Points - **Automatic**: Capture starts when you reject an NES suggestion (if `onReject` setting is enabled) -- **Manual**: Use the keyboard shortcut or Command Palette when NES didn't appear but should have +- **Manual**: Run **"Copilot: Record Expected Edit (NES)"** from the Command Palette when NES didn't appear but should have ### Capture Session When capture mode is active: -1. A status bar indicator shows: **"NES CAPTURE MODE ACTIVE"** -2. Type your expected edit naturally in the editor -3. Press **Enter** to save or **Esc** to cancel +1. An animated status bar indicator shows: **"NES CAPTURE MODE ACTIVE"** (with an error background for visibility). Hovering it reveals the available keybindings. +2. Type your expected edit naturally in the editor (Enter inserts a newline as usual) +3. Press **Cmd+Enter** / **Ctrl+Enter** to save (saving with no edits records "no edit expected"), or **Esc** to cancel ### Where Captures Are Saved Recordings are stored in your workspace under `.copilot/nes-feedback/`: