From 5afbf630c1211b9be8d1946c3b6455a6408f9e8e Mon Sep 17 00:00:00 2001 From: Nico0248 Date: Fri, 22 May 2026 07:33:03 +0000 Subject: [PATCH] feat: add commit and sync skill buttons to Changes View for agent-host sessions (#317937) Add Commit and Sync skill buttons to the Changes View toolbar for agent-host sessions, complementing the existing Merge/PR dropdown. - Commit button: visible when worktree has uncommitted changes, shows loading spinner during git operations - Sync button: visible when upstream exists with incoming/outgoing changes, shows outgoing count badge and loading spinner - Both buttons registered on AgentsChangesToolbar via new optional menuId field on IAgentHostSkillButtonSpec - Unit tests cover toolbar registration, context key gating, and exported ID validation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../contrib/changes/browser/changesView.ts | 21 ++++++++++- .../browser/agentHostSkillButtons.ts | 36 ++++++++++++++++++- .../browser/agentHostSkillButtons.test.ts | 28 ++++++++++++--- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index abeadbcbb220b..1156738117d67 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -69,7 +69,7 @@ import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/ import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; import { ChecksViewModel } from './checksViewModel.js'; // eslint-disable-next-line local/code-import-patterns -- TODO: move skill button constants out of providers -import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from '../../providers/agentHost/browser/agentHostSkillButtons.js'; +import { AGENT_HOST_SKILL_BUTTON_COMMIT_ID, AGENT_HOST_SKILL_BUTTON_SYNC_ID, AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from '../../providers/agentHost/browser/agentHostSkillButtons.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, IsolationMode } from '../common/changes.js'; import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js'; import { ChangesViewModel } from './changesViewModel.js'; @@ -211,6 +211,25 @@ class ChangesButtonBarWidget extends Disposable { } return { showIcon: false, showLabel: true, isSecondary: false, customLabel: `$(loading) ${labelWithCount}` }; } + if (action.id === AGENT_HOST_SKILL_BUTTON_COMMIT_ID) { + if (!hasGitOperationInProgress) { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + const customLabelObs = derived(reader => { + const running = runningLabelObs.read(reader); + return `$(loading) ${running ?? action.label}`; + }); + return { showIcon: false, showLabel: true, isSecondary: false, customLabelObs }; + } + if (action.id === AGENT_HOST_SKILL_BUTTON_SYNC_ID) { + const labelWithCount = outgoingChanges > 0 + ? `${action.label} ${outgoingChanges}↑` + : action.label; + if (!hasGitOperationInProgress) { + return { showIcon: true, showLabel: true, isSecondary: false, customLabel: labelWithCount }; + } + return { showIcon: false, showLabel: true, isSecondary: false, customLabel: `$(loading) ${labelWithCount}` }; + } if ( action.id === 'github.copilot.claude.sessions.sync' || action.id === AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSkillButtons.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSkillButtons.ts index e4221f32b2abc..242fd170c94b7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSkillButtons.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSkillButtons.ts @@ -79,11 +79,43 @@ interface IAgentHostSkillButtonSpec { readonly group: string; readonly order: number; readonly extraWhen: ContextKeyExpression | undefined; + /** Menu to register the button on. Defaults to {@link MenuId.AgentsChangesPrimaryActionSubMenu}. */ + readonly menuId?: MenuId; } const AGENT_HOST_SKILL_BUTTON_ID_PREFIX = 'workbench.action.agentSessions.runSkill.'; const AGENT_HOST_SKILL_BUTTONS: readonly IAgentHostSkillButtonSpec[] = [ + { + id: `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}commit`, + title: localize2('agentSessions.runSkill.commit', "Commit"), + skill: 'commit', + icon: Codicon.check, + group: 'navigation', + order: 0, + menuId: MenuId.AgentsChangesToolbar, + extraWhen: ContextKeyExpr.and( + ActiveSessionContextKeys.IsolationMode.isEqualTo(IsolationMode.Worktree), + ActiveSessionContextKeys.HasUncommittedChanges, + ), + }, + { + id: `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}sync`, + title: localize2('agentSessions.runSkill.sync', "Sync"), + skill: 'sync', + icon: Codicon.sync, + group: 'navigation', + order: 0, + menuId: MenuId.AgentsChangesToolbar, + extraWhen: ContextKeyExpr.and( + ActiveSessionContextKeys.IsolationMode.isEqualTo(IsolationMode.Worktree), + ActiveSessionContextKeys.HasUpstream, + ContextKeyExpr.or( + ActiveSessionContextKeys.HasIncomingChanges, + ActiveSessionContextKeys.HasOutgoingChanges, + ), + ), + }, { id: `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}merge`, title: localize2('agentSessions.runSkill.merge', "Merge Changes"), @@ -152,6 +184,8 @@ const AGENT_HOST_SKILL_BUTTONS: readonly IAgentHostSkillButtonSpec[] = [ * as the Copilot CLI extension's Sync PR button. Exported so the changes * view can pick it out of the toolbar without re-deriving the ID. */ +export const AGENT_HOST_SKILL_BUTTON_COMMIT_ID = `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}commit`; +export const AGENT_HOST_SKILL_BUTTON_SYNC_ID = `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}sync`; export const AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID = `${AGENT_HOST_SKILL_BUTTON_ID_PREFIX}updatePR`; /** @@ -171,7 +205,7 @@ function registerAgentHostSkillButton(spec: IAgentHostSkillButtonSpec): void { icon: spec.icon, f1: false, menu: { - id: MenuId.AgentsChangesPrimaryActionSubMenu, + id: spec.menuId ?? MenuId.AgentsChangesPrimaryActionSubMenu, group: spec.group, order: spec.order, when: ContextKeyExpr.and( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index 961dcf0b6599f..ddf19b95bf233 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -18,7 +18,7 @@ import { IChat } from '../../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; -import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, IsAgentHostSession, IsAgentHostSessionContextContribution, isAgentHostSkillButtonId } from '../../browser/agentHostSkillButtons.js'; +import { AGENT_HOST_SKILL_BUTTON_COMMIT_ID, AGENT_HOST_SKILL_BUTTON_SYNC_ID, AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, IsAgentHostSession, IsAgentHostSessionContextContribution, isAgentHostSkillButtonId } from '../../browser/agentHostSkillButtons.js'; import { BaseAgentHostSessionsProvider } from '../../browser/baseAgentHostSessionsProvider.js'; // Importing this contribution registers the apply submenu on the changes toolbar, // which is the slot that hosts our skill buttons as a dropdown. @@ -163,8 +163,8 @@ suite('agentHostSkillButtons - menu registration', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function skillButtonItems() { - const all = MenuRegistry.getMenuItems(MenuId.AgentsChangesPrimaryActionSubMenu); + function skillButtonItems(menuId: MenuId = MenuId.AgentsChangesPrimaryActionSubMenu) { + const all = MenuRegistry.getMenuItems(menuId); const menuItems: { command: { id: string }; when?: ContextKeyExpression }[] = []; for (const item of all) { if (!isIMenuItem(item)) { @@ -187,8 +187,16 @@ suite('agentHostSkillButtons - menu registration', () => { ]); }); + test('registers commit and sync skill buttons on the changes toolbar', () => { + const ids = skillButtonItems(MenuId.AgentsChangesToolbar).map(item => item.command.id).sort(); + assert.deepStrictEqual(ids, [ + 'workbench.action.agentSessions.runSkill.commit', + 'workbench.action.agentSessions.runSkill.sync', + ]); + }); + test('every skill button `when` clause includes sessions.isAgentHostSession and isSessionsWindow', () => { - for (const item of skillButtonItems()) { + for (const item of [...skillButtonItems(), ...skillButtonItems(MenuId.AgentsChangesToolbar)]) { const whenStr = item.when?.serialize() ?? ''; assert.ok( whenStr.includes(IsAgentHostSession.key), @@ -207,6 +215,18 @@ suite('agentHostSkillButtons - menu registration', () => { `expected command ${AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID} to be registered`); }); + test('exported commit id matches the registered command', () => { + assert.ok(isAgentHostSkillButtonId(AGENT_HOST_SKILL_BUTTON_COMMIT_ID)); + assert.ok(CommandsRegistry.getCommand(AGENT_HOST_SKILL_BUTTON_COMMIT_ID), + `expected command ${AGENT_HOST_SKILL_BUTTON_COMMIT_ID} to be registered`); + }); + + test('exported sync id matches the registered command', () => { + assert.ok(isAgentHostSkillButtonId(AGENT_HOST_SKILL_BUTTON_SYNC_ID)); + assert.ok(CommandsRegistry.getCommand(AGENT_HOST_SKILL_BUTTON_SYNC_ID), + `expected command ${AGENT_HOST_SKILL_BUTTON_SYNC_ID} to be registered`); + }); + test('the apply submenu is contributed to the changes toolbar in the navigation group', () => { const toolbarItems = MenuRegistry.getMenuItems(MenuId.AgentsChangesToolbar); const submenuEntry = toolbarItems.find(item => isISubmenuItem(item) && item.submenu === MenuId.AgentsChangesPrimaryActionSubMenu);