Skip to content

Commit 5d73fbc

Browse files
committed
Agent Host changes for lszomoru/agents/branch-picker-uncommitted-action
1 parent df814e6 commit 5d73fbc

5 files changed

Lines changed: 183 additions & 17 deletions

File tree

‎src/vs/platform/actionWidget/browser/actionList.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IAction
392392
data.detail.textContent = '';
393393
data.detail.style.display = 'none';
394394
}
395+
data.container.classList.toggle('has-detail', !!element.detail);
395396

396397
// Render optional inline toggle (shown as its own row below the detail)
397398
dom.clearNode(data.inlineToggleContainer);

‎src/vs/platform/actionWidget/browser/actionWidget.css‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -315,15 +315,13 @@
315315
}
316316

317317
/* Items with detail — show detail as subtext below the title */
318-
.action-widget .monaco-list .monaco-list-row.action {
319-
&:has(.detail:not([style*="display: none"])) {
320-
flex-wrap: wrap;
321-
align-content: center;
322-
padding-right: 6px;
323-
324-
.title {
325-
line-height: 14px;
326-
}
318+
.action-widget .monaco-list .monaco-list-row.action.has-detail {
319+
flex-wrap: wrap;
320+
align-content: center;
321+
padding-right: 6px;
322+
323+
.title {
324+
line-height: 14px;
327325
}
328326
}
329327

@@ -467,9 +465,16 @@
467465
display: none;
468466
}
469467

468+
.action-widget .monaco-list-row.action.has-detail.has-toolbar .action-list-item-toolbar {
469+
display: flex;
470+
visibility: hidden;
471+
margin-right: 10px;
472+
}
473+
470474
.action-widget .monaco-list-row.focused.action.has-toolbar .action-list-item-toolbar,
471475
.action-widget .monaco-list-row:hover.action.has-toolbar .action-list-item-toolbar {
472476
display: flex;
477+
visibility: visible;
473478
}
474479

475480
.action-widget .monaco-list-row .action-list-item-toolbar .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover {

‎src/vs/platform/actionWidget/test/browser/actionList.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,72 @@ suite('ActionListWidget', () => {
357357
});
358358
});
359359

360+
test('keeps detail row geometry stable when its toolbar becomes visible', () => {
361+
const widget = createActionListWidget(disposables, {
362+
items: [
363+
action('plain'),
364+
{ ...action('detail'), detail: 'Description', toolbarActions: [toAction({ id: 'toolbar', label: 'Toolbar', run: () => { } })] },
365+
...Array.from({ length: 20 }, (_, index) => action(`filler-${index}`)),
366+
],
367+
});
368+
const wrapper = document.createElement('div');
369+
wrapper.classList.add('action-widget');
370+
widget.domNode.parentElement?.insertBefore(wrapper, widget.domNode);
371+
wrapper.appendChild(widget.domNode);
372+
disposables.add({ dispose: () => wrapper.remove() });
373+
374+
const rows = Array.from(widget.domNode.querySelectorAll<HTMLElement>('.monaco-list-row'));
375+
const detailRow = rows[1];
376+
const detail = detailRow.querySelector<HTMLElement>('.detail')!;
377+
const toolbar = detailRow.querySelector<HTMLElement>('.action-list-item-toolbar')!;
378+
const verticalScrollbar = widget.domNode.querySelector<HTMLElement>('.scrollbar.vertical')!;
379+
const initial = {
380+
rowHeight: detailRow.getBoundingClientRect().height,
381+
detailTop: detail.getBoundingClientRect().top,
382+
toolbarDisplay: mainWindow.getComputedStyle(toolbar).display,
383+
toolbarVisibility: mainWindow.getComputedStyle(toolbar).visibility,
384+
toolbarMarginRight: mainWindow.getComputedStyle(toolbar).marginRight,
385+
};
386+
detailRow.classList.add('focused');
387+
const focused = {
388+
rowHeight: detailRow.getBoundingClientRect().height,
389+
detailTop: detail.getBoundingClientRect().top,
390+
toolbarDisplay: mainWindow.getComputedStyle(toolbar).display,
391+
toolbarVisibility: mainWindow.getComputedStyle(toolbar).visibility,
392+
toolbarMarginRight: mainWindow.getComputedStyle(toolbar).marginRight,
393+
clearsScrollbar: detailRow.getBoundingClientRect().right - toolbar.getBoundingClientRect().right >= verticalScrollbar.getBoundingClientRect().width,
394+
};
395+
396+
assert.deepStrictEqual({
397+
rows: rows.slice(0, 2).map(row => ({
398+
hasDetail: row.classList.contains('has-detail'),
399+
hasToolbar: row.classList.contains('has-toolbar'),
400+
})),
401+
initial,
402+
focused,
403+
}, {
404+
rows: [
405+
{ hasDetail: false, hasToolbar: false },
406+
{ hasDetail: true, hasToolbar: true },
407+
],
408+
initial: {
409+
rowHeight: 48,
410+
detailTop: initial.detailTop,
411+
toolbarDisplay: 'flex',
412+
toolbarVisibility: 'hidden',
413+
toolbarMarginRight: '6px',
414+
},
415+
focused: {
416+
rowHeight: 48,
417+
detailTop: initial.detailTop,
418+
toolbarDisplay: 'flex',
419+
toolbarVisibility: 'visible',
420+
toolbarMarginRight: '6px',
421+
clearsScrollbar: true,
422+
},
423+
});
424+
});
425+
360426
test('keeps titled separator above first filtered match', () => {
361427
const widget = createActionListWidget(disposables, {
362428
items: [

‎src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../
1111
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
1212
import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js';
1313
import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js';
14+
import { toAction } from '../../../../../base/common/actions.js';
1415
import { Delayer } from '../../../../../base/common/async.js';
1516
import { Codicon } from '../../../../../base/common/codicons.js';
1617
import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
@@ -35,10 +36,14 @@ import { markOnboardingTarget } from '../../../../../workbench/contrib/onboardin
3536
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
3637
import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';
3738
import { IChatInputPickerResponsiveState } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js';
39+
import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js';
40+
import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js';
3841
import { Menus } from '../../../../browser/menus.js';
3942
import { SessionProviderIdContext, IsPhoneLayoutContext, IsQuickChatSessionContext } from '../../../../common/contextkeys.js';
4043
import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js';
4144
import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js';
45+
import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js';
46+
import { CHANGES_VIEW_ID } from '../../../changes/common/changes.js';
4247
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
4348
import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js';
4449
import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js';
@@ -144,7 +149,7 @@ function getBranchUncommittedChanges(branchName: string, repositoryBranchName: s
144149
: undefined;
145150
}
146151

147-
function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined, policyRestricted?: boolean, repositoryBranchName?: string, repositoryUncommittedChanges?: number): IActionListItem<IConfigPickerItem>[] {
152+
function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined, policyRestricted?: boolean, repositoryBranchName?: string, repositoryUncommittedChanges?: number, onShowChanges?: () => Promise<void>): IActionListItem<IConfigPickerItem>[] {
148153
const actionItems: IActionListItem<IConfigPickerItem>[] = items.map(item => {
149154
const disabled = property === SessionConfigKey.AutoApprove && isAutoApproveValuePolicyRestricted(item.value, policyRestricted === true);
150155
const checked = isSelectedValue(currentValue, item.value);
@@ -162,6 +167,14 @@ function toActionItems(property: string, items: readonly IConfigPickerItem[], cu
162167
ariaDescription: uncommittedChangesDescription,
163168
disabled,
164169
item: { ...item, checked },
170+
toolbarActions: uncommittedChanges !== undefined && onShowChanges
171+
? [toAction({
172+
id: 'sessions.agentHost.showBranchChanges',
173+
label: localize('agentHostSessionConfig.branchItemShowChanges', "Show Changes"),
174+
class: ThemeIcon.asClassName(Codicon.diffMultiple),
175+
run: onShowChanges,
176+
})]
177+
: undefined,
165178
};
166179
});
167180

@@ -364,8 +377,10 @@ export class AgentHostSessionConfigPicker extends Disposable {
364377
@IHoverService protected readonly _hoverService: IHoverService,
365378
@ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,
366379
@ITelemetryService protected readonly _telemetryService: ITelemetryService,
367-
@IWorkbenchLayoutService protected readonly _layoutService: IWorkbenchLayoutService,
380+
@IAgentWorkbenchLayoutService protected readonly _layoutService: IAgentWorkbenchLayoutService,
368381
@IStorageService protected readonly _storageService: IStorageService,
382+
@ISessionChangesService private readonly _sessionChangesService: ISessionChangesService,
383+
@IViewsService protected readonly _viewsService: IViewsService,
369384
) {
370385
super();
371386

@@ -785,7 +800,10 @@ export class AgentHostSessionConfigPicker extends Disposable {
785800
const repositoryState = property === SessionConfigKey.Branch
786801
? this._getRepositoryBranchState(sessionId)
787802
: undefined;
788-
const actionItems = toActionItems(property, items, currentValue, policyRestricted, repositoryState?.branchName, repositoryState?.uncommittedChanges);
803+
const onShowChanges = property === SessionConfigKey.Branch
804+
? () => this._showChanges()
805+
: undefined;
806+
const actionItems = toActionItems(property, items, currentValue, policyRestricted, repositoryState?.branchName, repositoryState?.uncommittedChanges, onShowChanges);
789807

790808
const delegate: IActionListDelegate<IConfigPickerItem> = {
791809
onSelect: async item => {
@@ -816,7 +834,7 @@ export class AgentHostSessionConfigPicker extends Disposable {
816834
const filteredRawItems = await this._getItems(provider, sessionId, property, schema, query);
817835
const { items: filteredItems, policyRestricted: filteredPolicyRestricted } = applyAutoApproveFiltering(filteredRawItems, property, this._configurationService);
818836
const filteredRepositoryState = this._getRepositoryBranchState(sessionId);
819-
return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property] ?? schema.default, filteredPolicyRestricted, filteredRepositoryState.branchName, filteredRepositoryState.uncommittedChanges);
837+
return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property] ?? schema.default, filteredPolicyRestricted, filteredRepositoryState.branchName, filteredRepositoryState.uncommittedChanges, onShowChanges);
820838
})
821839
: undefined,
822840
onHide: () => trigger.focus(),
@@ -842,6 +860,20 @@ export class AgentHostSessionConfigPicker extends Disposable {
842860
);
843861
}
844862

863+
private async _showChanges(): Promise<void> {
864+
this._actionWidgetService.hide();
865+
const session = this._session.get();
866+
if (this._layoutService.isSinglePaneLayoutEnabled && session) {
867+
const suppression = this._layoutService.suppressEditorPartAutoVisibility();
868+
try {
869+
await this._sessionChangesService.openChangesEditor(session.resource);
870+
} finally {
871+
suppression.dispose();
872+
}
873+
}
874+
await this._viewsService.openView(CHANGES_VIEW_ID, true);
875+
}
876+
845877
protected _getRepositoryBranchState(sessionId: string): { branchName: string | undefined; uncommittedChanges: number | undefined } {
846878
const session = this._session.get();
847879
const repository = session?.sessionId === sessionId

‎src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts‎

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,21 @@ import { TestInstantiationService } from '../../../../../../../platform/instanti
2323
import { IStorageService } from '../../../../../../../platform/storage/common/storage.js';
2424
import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js';
2525
import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js';
26-
import { IWorkbenchLayoutService } from '../../../../../../../workbench/services/layout/browser/layoutService.js';
26+
import { IView } from '../../../../../../../workbench/common/views.js';
27+
import { IViewsService } from '../../../../../../../workbench/services/views/common/viewsService.js';
28+
import { IAgentWorkbenchLayoutService } from '../../../../../../browser/workbench.js';
2729
import { Menus } from '../../../../../../browser/menus.js';
2830
import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../../common/agentHostSessionsProvider.js';
31+
import { ISessionChangesService } from '../../../../../../contrib/changes/browser/sessionChangesService.js';
32+
import { CHANGES_VIEW_ID } from '../../../../../../contrib/changes/common/changes.js';
2933
import { ISessionsProvidersService } from '../../../../../../services/sessions/browser/sessionsProvidersService.js';
3034
import { IActiveSession } from '../../../../../../services/sessions/common/sessionsManagement.js';
3135
import { ISessionWorkspace } from '../../../../../../services/sessions/common/session.js';
3236
import { ISessionsProvider } from '../../../../../../services/sessions/common/sessionsProvider.js';
3337
import { AgentHostSessionConfigPicker, IConfigPickerItem, PickerActionViewItem } from '../../../browser/agentHostSessionConfigPicker.js';
3438

3539
const SESSION_ID = 'local-agent-host:s1';
40+
const SESSION_RESOURCE = URI.parse('agent-session:/s1');
3641

3742
function makeWorkspace(uncommittedChanges: number | undefined, branchName = 'main'): ISessionWorkspace {
3843
const root = URI.file('/repo');
@@ -195,6 +200,7 @@ function branchState(container: HTMLElement): { icon: string | undefined; ariaLa
195200
class CapturingActionWidgetHolder {
196201
delegate: IActionListDelegate<IConfigPickerItem> | undefined;
197202
items: readonly IActionListItem<IConfigPickerItem>[] = [];
203+
readonly events: string[] = [];
198204
}
199205

200206
function setupServices(store: Pick<ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>, 'add'>) {
@@ -205,7 +211,7 @@ function setupServices(store: Pick<ReturnType<typeof ensureNoDisposablesAreLeake
205211
const instantiationService = store.add(new TestInstantiationService());
206212
instantiationService.stub(IActionWidgetService, {
207213
isVisible: false,
208-
hide: () => { },
214+
hide: () => actionWidget.events.push('hide'),
209215
show: (_user, _supportsPreview, items: readonly IActionListItem<IConfigPickerItem>[], delegate: IActionListDelegate<IConfigPickerItem>) => {
210216
actionWidget.items = items;
211217
actionWidget.delegate = delegate;
@@ -219,9 +225,26 @@ function setupServices(store: Pick<ReturnType<typeof ensureNoDisposablesAreLeake
219225
instantiationService.stub(IContextKeyService, new (class extends mock<IContextKeyService>() {
220226
override readonly onDidChangeContext = Event.None;
221227
})());
222-
instantiationService.stub(IWorkbenchLayoutService, new (class extends mock<IWorkbenchLayoutService>() {
228+
instantiationService.stub(IAgentWorkbenchLayoutService, new (class extends mock<IAgentWorkbenchLayoutService>() {
223229
// No `phone-layout` class → `isPhoneLayout` is false → isolation renders as a checkbox.
224230
override readonly mainContainer = document.createElement('div');
231+
override readonly isSinglePaneLayoutEnabled = true;
232+
override suppressEditorPartAutoVisibility() {
233+
actionWidget.events.push('suppressEditorPartAutoVisibility');
234+
return { dispose: () => actionWidget.events.push('releaseEditorPartAutoVisibility') };
235+
}
236+
})());
237+
instantiationService.stub(ISessionChangesService, new (class extends mock<ISessionChangesService>() {
238+
override async openChangesEditor(sessionResource: URI): Promise<undefined> {
239+
actionWidget.events.push(`openChangesEditor:${sessionResource.toString()}`);
240+
return undefined;
241+
}
242+
})());
243+
instantiationService.stub(IViewsService, new (class extends mock<IViewsService>() {
244+
override async openView<T extends IView>(id: string, focus?: boolean): Promise<T | null> {
245+
actionWidget.events.push(`openView:${id}:${focus}`);
246+
return null;
247+
}
225248
})());
226249
instantiationService.set(ISessionsProvidersService, new (class extends mock<ISessionsProvidersService>() {
227250
override readonly onDidChangeProviders = Event.None;
@@ -236,6 +259,7 @@ function setupServices(store: Pick<ReturnType<typeof ensureNoDisposablesAreLeake
236259
const sessionObs = observableValue<IActiveSession | undefined>('activeSession', {
237260
providerId: LOCAL_AGENT_HOST_PROVIDER_ID,
238261
sessionId: SESSION_ID,
262+
resource: SESSION_RESOURCE,
239263
workspace,
240264
} as IActiveSession);
241265
return { instantiationService, provider, sessionObs, workspaceObs, actionWidget };
@@ -400,6 +424,7 @@ suite('Agent Host Session Config Picker', () => {
400424
checked: item.item?.checked,
401425
detail: item.detail,
402426
ariaDescription: item.ariaDescription,
427+
toolbarActions: item.toolbarActions?.map(action => ({ id: action.id, label: action.label })),
403428
}));
404429

405430
services.workspaceObs.set(makeWorkspace(1, 'dev'), undefined);
@@ -412,13 +437,19 @@ suite('Agent Host Session Config Picker', () => {
412437
ariaDescription: singularItem?.ariaDescription,
413438
};
414439

440+
services.workspaceObs.set(makeWorkspace(0, 'dev'), undefined);
441+
branchSlot(container)!.querySelector<HTMLElement>('a.action-label')!
442+
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
443+
await new Promise(resolve => setTimeout(resolve));
444+
const cleanToolbarActions = services.actionWidget.items.find(item => item.label === 'dev')?.toolbarActions;
445+
415446
services.provider.completions = [{ value: 'main', label: 'main' }];
416447
branchSlot(container)!.querySelector<HTMLElement>('a.action-label')!
417448
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
418449
await new Promise(resolve => setTimeout(resolve));
419450
const singleResultKinds = services.actionWidget.items.map(item => item.kind);
420451

421-
assert.deepStrictEqual({ plural, singular, singleResultKinds }, {
452+
assert.deepStrictEqual({ plural, singular, cleanToolbarActions, singleResultKinds }, {
422453
plural: [
423454
{
424455
kind: ActionListItemKind.Action,
@@ -427,6 +458,7 @@ suite('Agent Host Session Config Picker', () => {
427458
checked: true,
428459
detail: undefined,
429460
ariaDescription: undefined,
461+
toolbarActions: undefined,
430462
},
431463
{
432464
kind: ActionListItemKind.Separator,
@@ -435,6 +467,7 @@ suite('Agent Host Session Config Picker', () => {
435467
checked: undefined,
436468
detail: undefined,
437469
ariaDescription: undefined,
470+
toolbarActions: undefined,
438471
},
439472
{
440473
kind: ActionListItemKind.Action,
@@ -443,16 +476,45 @@ suite('Agent Host Session Config Picker', () => {
443476
checked: false,
444477
detail: '2 uncommitted files',
445478
ariaDescription: '2 uncommitted files',
479+
toolbarActions: [{
480+
id: 'sessions.agentHost.showBranchChanges',
481+
label: 'Show Changes',
482+
}],
446483
},
447484
],
448485
singular: {
449486
detail: '1 uncommitted file',
450487
ariaDescription: '1 uncommitted file',
451488
},
489+
cleanToolbarActions: undefined,
452490
singleResultKinds: [ActionListItemKind.Action],
453491
});
454492
});
455493

494+
test('dirty branch action selects the Changes tab before focusing the Changes view', async () => {
495+
const services = setupServices(store);
496+
services.provider.config = makeDynamicBranchConfig('main');
497+
services.provider.completions = [
498+
{ value: 'main', label: 'main' },
499+
{ value: 'dev', label: 'dev' },
500+
];
501+
services.workspaceObs.set(makeWorkspace(1, 'dev'), undefined);
502+
const { container } = renderPicker(store, services);
503+
504+
branchSlot(container)!.querySelector<HTMLElement>('a.action-label')!.click();
505+
await new Promise(resolve => setTimeout(resolve));
506+
const action = services.actionWidget.items.find(item => item.label === 'dev')?.toolbarActions?.[0];
507+
await action?.run();
508+
509+
assert.deepStrictEqual(services.actionWidget.events, [
510+
'hide',
511+
'suppressEditorPartAutoVisibility',
512+
`openChangesEditor:${SESSION_RESOURCE.toString()}`,
513+
'releaseEditorPartAutoVisibility',
514+
`openView:${CHANGES_VIEW_ID}:true`,
515+
]);
516+
});
517+
456518
test('a picker recreated on a session switch still renders the provider-seeded chips (disabled) while resolving', () => {
457519
const services = setupServices(store);
458520
const { provider } = services;

0 commit comments

Comments
 (0)