Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import '../../../browser/media/sidebarActionButton.css';
7
import './media/accountWidget.css';
8
import './media/accountTitleBarWidget.css';
9
import '../../../../workbench/contrib/chat/browser/chatStatus/media/chatStatus.css';
10
import Severity from '../../../../base/common/severity.js';
11
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
12
import { localize, localize2 } from '../../../../nls.js';
13
import { Action2, MenuRegistry, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js';
14
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
15
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
16
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
17
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
18
import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js';
19
import { Menus } from '../../../browser/menus.js';
20
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
21
import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
22
import { $, addDisposableListener, append, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js';
23
import { mainWindow } from '../../../../base/browser/window.js';
24
import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
25
import { IAction, Separator } from '../../../../base/common/actions.js';
26
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
27
import { Codicon } from '../../../../base/common/codicons.js';
28
import { IUpdateService, State, StateType } from '../../../../platform/update/common/update.js';
29
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
30
import { IProductService } from '../../../../platform/product/common/productService.js';
31
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
32
import { IHostService } from '../../../../workbench/services/host/browser/host.js';
33
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
34
import { URI } from '../../../../base/common/uri.js';
35
import { isWindows, isMacintosh } from '../../../../base/common/platform.js';
36
import { UpdateHoverWidget } from './updateHoverWidget.js';
37
import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js';
38
import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js';
39
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
40
import { ThemeIcon } from '../../../../base/common/themables.js';
41
import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, resolveAccountInfo } from '../../../browser/accountTitleBarState.js';
42
import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js';
43
import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';
44
import { IAuthenticationAccessService } from '../../../../workbench/services/authentication/browser/authenticationAccessService.js';
45
import { IAuthenticationUsageService } from '../../../../workbench/services/authentication/browser/authenticationUsageService.js';
46
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
47
import { IChatDashboardService } from '../../../browser/chatDashboardService.js';
48
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
49
50
// --- Account Menu Items --- //
51
const AccountMenu = Menus.AccountMenu;
52
const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidget';
53
const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget';
54
const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 360;
55
56
const PERSONALIZE_ACTION_IDS: readonly string[] = [
57
'workbench.action.openSettings',
58
'workbench.action.openGlobalKeybindings',
59
'workbench.action.selectTheme',
60
];
61
const SIGN_OUT_ACTION_ID = 'workbench.action.agenticSignOut';
62
const SIGN_IN_ACTION_ID = 'workbench.action.agenticSignIn';
63
64
function shouldHideSessionsTitleBarUpdateWidget(type: StateType): boolean {
65
return type === StateType.Uninitialized
66
|| type === StateType.Idle
67
|| type === StateType.Disabled
68
|| type === StateType.CheckingForUpdates;
69
}
70
71
function isPrimarySessionsTitleBarUpdateWidget(type: StateType): boolean {
72
return type === StateType.AvailableForDownload
73
|| type === StateType.Downloaded
74
|| type === StateType.Ready;
75
}
76
77
function isBusySessionsTitleBarUpdateWidget(type: StateType): boolean {
78
return type === StateType.Downloading
79
|| type === StateType.Overwriting
80
|| type === StateType.Updating
81
|| type === StateType.Restarting;
82
}
83
84
function getSessionsTitleBarUpdateLabel(state: State): string {
85
switch (state.type) {
86
case StateType.AvailableForDownload:
87
return localize('sessionsTitleBarUpdateAvailable', "Update Available");
88
case StateType.Downloaded:
89
return localize('sessionsTitleBarInstallUpdate', "Install Update");
90
case StateType.Ready:
91
return localize('sessionsTitleBarRestartToUpdate', "Restart to Update");
92
case StateType.Downloading:
93
case StateType.Overwriting:
94
return localize('sessionsTitleBarDownloading', "Downloading...");
95
case StateType.Updating:
96
case StateType.Restarting:
97
return localize('sessionsTitleBarInstalling', "Installing...");
98
default:
99
return localize('sessionsTitleBarUpdate', "Update");
100
}
101
}
102
103
function getSessionsTitleBarUpdateAriaLabel(state: State): string {
104
switch (state.type) {
105
case StateType.AvailableForDownload:
106
return localize('sessionsTitleBarUpdateAvailableAria', "Update available");
107
case StateType.Downloaded:
108
return localize('sessionsTitleBarInstallUpdateAria', "Install downloaded update");
109
case StateType.Ready:
110
return localize('sessionsTitleBarRestartToUpdateAria', "Restart to apply update");
111
case StateType.Downloading:
112
case StateType.Overwriting:
113
return localize('sessionsTitleBarDownloadingAria', "Update download in progress");
114
case StateType.Updating:
115
case StateType.Restarting:
116
return localize('sessionsTitleBarInstallingAria', "Update install in progress");
117
default:
118
return localize('sessionsTitleBarUpdateAria', "Update");
119
}
120
}
121
122
async function runSessionsUpdateAction(
123
state: State,
124
updateService: IUpdateService,
125
openerService: IOpenerService,
126
productService: IProductService,
127
dialogService: IDialogService,
128
hostService: IHostService,
129
): Promise<void> {
130
if (state.type === StateType.AvailableForDownload) {
131
const isInsiderOrExploration = productService.quality === 'insider' || productService.quality === 'exploration';
132
const hasCrossAppCoordinator = (isWindows || isMacintosh) && isInsiderOrExploration;
133
if (!hasCrossAppCoordinator) {
134
const { confirmed } = await dialogService.confirm({
135
message: localize('sessionsUpdateFromVSCode.title', "Update from VS Code"),
136
detail: localize('sessionsUpdateFromVSCode.detail', "This will close the Agents app and open VS Code so you can install the update.\n\nLaunch Agents again after the update is complete."),
137
primaryButton: localize('sessionsUpdateFromVSCode.open', "Close and Open VS Code"),
138
});
139
140
if (confirmed) {
141
await openerService.open(URI.from({
142
scheme: productService.urlProtocol,
143
query: 'windowId=_blank',
144
}), { openExternal: true });
145
await hostService.shutdown();
146
}
147
148
return;
149
}
150
151
await updateService.downloadUpdate(true);
152
return;
153
}
154
155
if (state.type === StateType.Ready) {
156
await updateService.quitAndInstall();
157
return;
158
}
159
160
if (state.type === StateType.Downloaded) {
161
await updateService.applyUpdate();
162
}
163
}
164
165
// Sign In (shown when signed out)
166
registerAction2(class extends Action2 {
167
constructor() {
168
super({
169
id: 'workbench.action.agenticSignIn',
170
title: localize2('signIn', 'Sign In'),
171
menu: {
172
id: AccountMenu,
173
when: ContextKeyExpr.notEquals('defaultAccountStatus', 'available'),
174
group: '1_account',
175
order: 1,
176
}
177
});
178
}
179
async run(accessor: ServicesAccessor): Promise<void> {
180
const defaultAccountService = accessor.get(IDefaultAccountService);
181
await defaultAccountService.signIn();
182
}
183
});
184
185
// Sign Out (shown when signed in)
186
registerAction2(class extends Action2 {
187
constructor() {
188
super({
189
id: 'workbench.action.agenticSignOut',
190
title: localize2('signOut', 'Sign Out'),
191
menu: {
192
id: AccountMenu,
193
when: ContextKeyExpr.equals('defaultAccountStatus', 'available'),
194
group: '1_account',
195
order: 1,
196
}
197
});
198
}
199
async run(accessor: ServicesAccessor): Promise<void> {
200
const defaultAccountService = accessor.get(IDefaultAccountService);
201
const dialogService = accessor.get(IDialogService);
202
const authenticationService = accessor.get(IAuthenticationService);
203
const authenticationUsageService = accessor.get(IAuthenticationUsageService);
204
const authenticationAccessService = accessor.get(IAuthenticationAccessService);
205
const defaultAccount = await defaultAccountService.getDefaultAccount();
206
if (!defaultAccount) {
207
return;
208
}
209
210
const providerId = defaultAccount.authenticationProvider.id;
211
const accountLabel = defaultAccount.accountName;
212
const { confirmed } = await dialogService.confirm({
213
type: Severity.Info,
214
message: localize('agenticSignOutMessage', "Sign out of the Agents app?"),
215
detail: localize('agenticSignOutDetail', "This will sign out '{0}' from the Agents app.", accountLabel),
216
primaryButton: localize({ key: 'agenticSignOutButton', comment: ['&& denotes a mnemonic'] }, "&&Sign Out")
217
});
218
219
if (!confirmed) {
220
return;
221
}
222
223
const allSessions = await authenticationService.getSessions(providerId);
224
const sessions = allSessions.filter(session => session.account.label === accountLabel);
225
await Promise.all(sessions.map(session => authenticationService.removeSession(providerId, session.id)));
226
authenticationUsageService.removeAccountUsage(providerId, accountLabel);
227
authenticationAccessService.removeAllowedExtensions(providerId, accountLabel);
228
}
229
});
230
231
// Color Theme (hidden on phone — no theme picker UI on mobile)
232
MenuRegistry.appendMenuItem(AccountMenu, {
233
command: {
234
id: 'workbench.action.selectTheme',
235
title: localize('selectColorTheme', "Color Theme"),
236
},
237
when: IsPhoneLayoutContext.negate(),
238
group: '2_settings',
239
order: 1,
240
});
241
242
// Settings (hidden on phone — no settings UI on mobile)
243
MenuRegistry.appendMenuItem(AccountMenu, {
244
command: {
245
id: 'workbench.action.openSettings',
246
title: localize('settings', "Settings"),
247
},
248
when: IsPhoneLayoutContext.negate(),
249
group: '2_settings',
250
order: 2,
251
});
252
253
// Keyboard Shortcuts (hidden on phone — no keybindings UI on mobile)
254
MenuRegistry.appendMenuItem(AccountMenu, {
255
command: {
256
id: 'workbench.action.openGlobalKeybindings',
257
title: localize('sessionsAccountMenu.keyboardShortcuts', "Keyboard Shortcuts"),
258
},
259
when: IsPhoneLayoutContext.negate(),
260
group: '2_settings',
261
order: 3,
262
});
263
264
// Update actions
265
registerUpdateMenuItems(AccountMenu, '3_updates');
266
267
class TitleBarAccountWidget extends BaseActionViewItem {
268
269
private container: HTMLElement | undefined;
270
private avatarElement: HTMLImageElement | undefined;
271
private iconElement: HTMLElement | undefined;
272
private labelElement: HTMLElement | undefined;
273
private badgeElement: HTMLElement | undefined;
274
private accountName: string | undefined;
275
private accountProviderId: string | undefined;
276
private accountProviderLabel: string | undefined;
277
private isAccountLoading = true;
278
private accountRequestCounter = 0;
279
private avatarRequestCounter = 0;
280
private currentAvatarUrl: string | undefined;
281
private loadedAvatarUrl: string | undefined;
282
private lastState: ReturnType<typeof getAccountTitleBarState>;
283
private isMenuVisible = false;
284
private lastBadgeKey: string | undefined;
285
private dismissedBadgeKey: string | undefined;
286
private readonly copilotDashboardStore = this._register(new MutableDisposable<DisposableStore>());
287
private readonly clickPanelDisposable = this._register(new MutableDisposable<DisposableStore>());
288
private readonly avatarLoadDisposable = this._register(new MutableDisposable());
289
290
constructor(
291
action: IAction,
292
options: IBaseActionViewItemOptions | undefined,
293
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
294
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
295
@IMenuService private readonly menuService: IMenuService,
296
@IContextKeyService private readonly contextKeyService: IContextKeyService,
297
@IHoverService private readonly hoverService: IHoverService,
298
@IInstantiationService private readonly instantiationService: IInstantiationService,
299
@IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService,
300
) {
301
super(undefined, action, options);
302
this.lastState = getAccountTitleBarState({
303
isAccountLoading: true,
304
entitlement: this.chatEntitlementService.entitlement,
305
sentiment: this.chatEntitlementService.sentiment,
306
quotas: this.chatEntitlementService.quotas,
307
});
308
309
this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount()));
310
this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount()));
311
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderState()));
312
this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderState()));
313
this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderState()));
314
this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderState()));
315
this.refreshAccount();
316
}
317
318
override setFocusable(_focusable: boolean): void {
319
// Don't let the ActionBar remove focusability - this widget must
320
// always be reachable via Tab even when a sibling item is hidden.
321
}
322
323
override render(container: HTMLElement): void {
324
super.render(container);
325
326
this.container = container;
327
container.classList.add('sessions-account-titlebar-widget');
328
container.setAttribute('role', 'button');
329
container.tabIndex = 0;
330
331
this.avatarElement = append(container, $('img.sessions-account-titlebar-widget-avatar', { alt: localize('accountAvatarAltFallback', "Account profile image"), draggable: 'false' })) as HTMLImageElement;
332
this.avatarElement.decoding = 'async';
333
this.avatarElement.referrerPolicy = 'no-referrer';
334
this.iconElement = append(container, $('.sessions-account-titlebar-widget-icon'));
335
this.labelElement = append(container, $('span.sessions-account-titlebar-widget-label'));
336
this.badgeElement = append(container, $('span.sessions-account-titlebar-widget-badge'));
337
338
this.renderState();
339
}
340
341
override onClick(): void {
342
if (!this.container) {
343
return;
344
}
345
346
this.showCombinedPanel();
347
}
348
349
private async refreshAccount(): Promise<void> {
350
const requestId = ++this.accountRequestCounter;
351
this.isAccountLoading = true;
352
this.renderState();
353
354
const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService);
355
if (requestId !== this.accountRequestCounter) {
356
return;
357
}
358
359
this.accountName = info?.accountName;
360
this.accountProviderId = info?.accountProviderId;
361
this.accountProviderLabel = info?.accountProviderLabel;
362
this.isAccountLoading = false;
363
this.refreshAvatar();
364
this.renderState();
365
}
366
367
private renderState(): void {
368
if (!this.container || !this.avatarElement || !this.iconElement || !this.labelElement || !this.badgeElement) {
369
return;
370
}
371
372
// When we have a session but entitlement hasn't resolved yet,
373
// treat as Unresolved to avoid showing "Agents Signed Out".
374
const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown
375
? ChatEntitlement.Unresolved
376
: this.chatEntitlementService.entitlement;
377
378
const state = getAccountTitleBarState({
379
isAccountLoading: this.isAccountLoading,
380
accountName: this.accountName,
381
accountProviderLabel: this.accountProviderLabel,
382
entitlement,
383
sentiment: this.chatEntitlementService.sentiment,
384
quotas: this.chatEntitlementService.quotas,
385
});
386
this.lastState = state;
387
388
this.container.classList.remove('kind-default', 'kind-accent', 'kind-warning', 'kind-prominent');
389
this.container.classList.add(`kind-${state.kind}`);
390
this.container.classList.toggle('menu-visible', this.isMenuVisible);
391
this.container.setAttribute('aria-label', state.ariaLabel);
392
393
const badgeKey = getAccountTitleBarBadgeKey(state);
394
if (badgeKey !== this.lastBadgeKey) {
395
this.lastBadgeKey = badgeKey;
396
this.dismissedBadgeKey = undefined;
397
}
398
399
const shouldShowDotBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey;
400
const loadedAvatarUrl = !this.isAccountLoading ? this.loadedAvatarUrl : undefined;
401
const hasLoadedAvatar = !!loadedAvatarUrl;
402
const titleBarIcon = state.dotBadge ? Codicon.account : state.icon;
403
404
this.avatarElement.classList.toggle('visible', hasLoadedAvatar);
405
this.avatarElement.alt = this.getAvatarAltText(hasLoadedAvatar);
406
if (hasLoadedAvatar) {
407
if (this.avatarElement.src !== loadedAvatarUrl) {
408
this.avatarElement.src = loadedAvatarUrl;
409
}
410
} else {
411
this.avatarElement.removeAttribute('src');
412
}
413
414
this.iconElement.className = `sessions-account-titlebar-widget-icon ${ThemeIcon.asClassName(titleBarIcon)}`;
415
this.iconElement.classList.toggle('hidden', hasLoadedAvatar);
416
this.labelElement.textContent = '';
417
this.badgeElement.textContent = '';
418
this.badgeElement.classList.toggle('dot-badge', shouldShowDotBadge);
419
this.badgeElement.classList.toggle('dot-badge-warning', shouldShowDotBadge && state.dotBadge === 'warning');
420
this.badgeElement.classList.toggle('dot-badge-error', shouldShowDotBadge && state.dotBadge === 'error');
421
this.badgeElement.style.display = shouldShowDotBadge ? '' : 'none';
422
}
423
424
private getAvatarAltText(hasLoadedAvatar: boolean): string {
425
if (hasLoadedAvatar && this.accountProviderId === 'github' && this.accountName) {
426
return localize('accountAvatarAlt', "GitHub profile image for {0}", this.accountName);
427
}
428
429
return localize('accountAvatarAltFallback', "Account profile image");
430
}
431
432
private refreshAvatar(): void {
433
const avatarUrl = getAccountProfileImageUrl(this.accountProviderId, this.accountName);
434
if (avatarUrl === this.currentAvatarUrl) {
435
return;
436
}
437
438
this.currentAvatarUrl = avatarUrl;
439
this.loadedAvatarUrl = undefined;
440
this.avatarLoadDisposable.clear();
441
const requestId = ++this.avatarRequestCounter;
442
443
if (!avatarUrl) {
444
this.renderState();
445
return;
446
}
447
448
const image = new Image();
449
image.referrerPolicy = 'no-referrer';
450
const clearHandlers = () => {
451
image.onload = null;
452
image.onerror = null;
453
};
454
image.onload = () => {
455
if (requestId !== this.avatarRequestCounter) {
456
return;
457
}
458
459
this.loadedAvatarUrl = avatarUrl;
460
this.renderState();
461
clearHandlers();
462
};
463
image.onerror = () => {
464
if (requestId !== this.avatarRequestCounter) {
465
return;
466
}
467
468
this.loadedAvatarUrl = undefined;
469
this.renderState();
470
clearHandlers();
471
};
472
this.avatarLoadDisposable.value = toDisposable(() => {
473
clearHandlers();
474
image.src = '';
475
});
476
image.src = avatarUrl;
477
this.renderState();
478
}
479
480
private getHoverTarget(): { targetElements: HTMLElement[]; x: number } {
481
const { left, width } = getDomNodePagePosition(this.container!);
482
return {
483
targetElements: [this.container!],
484
x: left + width - SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH,
485
};
486
}
487
488
private showCombinedPanel(): void {
489
if (!this.container) {
490
return;
491
}
492
493
if (this.isMenuVisible) {
494
this.hoverService.hideHover(true);
495
this.clickPanelDisposable.clear();
496
return;
497
}
498
499
this.hoverService.hideHover(true);
500
this.clickPanelDisposable.clear();
501
502
const panelStore = new DisposableStore();
503
this.clickPanelDisposable.value = panelStore;
504
505
const badgeKey = getAccountTitleBarBadgeKey(this.lastState);
506
if (badgeKey) {
507
this.dismissedBadgeKey = badgeKey;
508
}
509
510
this.isMenuVisible = true;
511
this.container.classList.add('menu-visible');
512
this.renderState();
513
514
panelStore.add({
515
dispose: () => {
516
this.isMenuVisible = false;
517
this.container?.classList.remove('menu-visible');
518
this.renderState();
519
this.container?.focus();
520
}
521
});
522
523
const panelContent = this.createCombinedPanelContent(panelStore);
524
const hoverWidget = this.hoverService.showInstantHover({
525
content: panelContent,
526
target: this.getHoverTarget(),
527
additionalClasses: ['sessions-account-titlebar-panel-hover'],
528
position: { hoverPosition: HoverPosition.BELOW },
529
persistence: { sticky: true, hideOnHover: false },
530
appearance: { showPointer: false, skipFadeInAnimation: true, maxHeightRatio: 0.8 },
531
}, true);
532
533
if (hoverWidget) {
534
panelStore.add(hoverWidget);
535
}
536
537
panelStore.add(disposableWindowInterval(mainWindow, () => {
538
if (!panelContent.isConnected || hoverWidget?.isDisposed) {
539
this.clickPanelDisposable.clear();
540
}
541
}, 500));
542
}
543
544
private createCombinedPanelContent(panelStore: DisposableStore): HTMLElement {
545
const panel = $('div.sessions-account-titlebar-panel');
546
547
// Build the menu actions once and partition them.
548
const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService);
549
const rawActions: IAction[] = [];
550
fillInActionBarActions(menu.getActions(), rawActions);
551
menu.dispose();
552
const partitioned = this.partitionMenuActions(rawActions);
553
554
// Header: account label + sign-out icon.
555
const headerSection = append(panel, $('.sessions-account-titlebar-panel-header'));
556
const loadedAvatarUrl = !this.isAccountLoading ? this.loadedAvatarUrl : undefined;
557
if (loadedAvatarUrl) {
558
const avatar = append(headerSection, $('img.sessions-account-titlebar-panel-avatar', {
559
alt: this.getAvatarAltText(true),
560
draggable: 'false',
561
src: loadedAvatarUrl,
562
})) as HTMLImageElement;
563
avatar.decoding = 'async';
564
avatar.referrerPolicy = 'no-referrer';
565
}
566
const title = append(headerSection, $('div.sessions-account-titlebar-panel-title'));
567
title.textContent = this.getPanelHeaderLabel();
568
if (partitioned.signOut) {
569
const headerActionsContainer = append(headerSection, $('.sessions-account-titlebar-panel-header-actions'));
570
this.createPanelButton(headerActionsContainer, partitioned.signOut, panelStore, {
571
classNames: ['sessions-account-titlebar-panel-header-action'],
572
icon: this.getHeaderActionIcon(partitioned.signOut),
573
});
574
}
575
576
// Personalize section.
577
if (partitioned.personalize.length > 0) {
578
const personalizeId = 'sessions-account-personalize-title';
579
const personalizeSection = append(panel, $('section.sessions-account-titlebar-panel-section', { 'aria-labelledby': personalizeId }));
580
const personalizeHeading = append(personalizeSection, $('div.sessions-account-titlebar-panel-section-title', { id: personalizeId }));
581
personalizeHeading.textContent = localize('sessionsAccountMenu.personalize', "Personalize");
582
const personalizeActionsContainer = append(personalizeSection, $('.sessions-account-titlebar-panel-actions'));
583
for (const action of partitioned.personalize) {
584
this.createPanelButton(personalizeActionsContainer, action, panelStore, {
585
classNames: ['sessions-account-titlebar-panel-action', 'with-icon'],
586
icon: this.getPersonalizeActionIcon(action),
587
includeLabel: true,
588
});
589
}
590
}
591
592
// Other panel actions (sign-in, etc.) — only render if there's at least one non-separator action.
593
if (partitioned.other.some(a => !(a instanceof Separator))) {
594
const actionsSection = append(panel, $('.sessions-account-titlebar-panel-actions'));
595
let lastWasSeparator = true;
596
for (const action of partitioned.other) {
597
if (action instanceof Separator) {
598
if (!lastWasSeparator) {
599
append(actionsSection, $('.sessions-account-titlebar-panel-separator'));
600
lastWasSeparator = true;
601
}
602
continue;
603
}
604
lastWasSeparator = false;
605
this.createPanelButton(actionsSection, action, panelStore, {
606
classNames: ['sessions-account-titlebar-panel-action'],
607
includeLabel: true,
608
checked: !!action.checked,
609
});
610
}
611
}
612
613
// Subscription / Copilot dashboard.
614
const contentSection = append(panel, $('.sessions-account-titlebar-panel-content'));
615
if (this.shouldShowCopilotDashboardHover()) {
616
const subscriptionId = 'sessions-account-subscription-title';
617
const subscriptionSection = append(contentSection, $('section.sessions-account-titlebar-panel-section.subscription', { 'aria-labelledby': subscriptionId }));
618
const subscriptionHeader = append(subscriptionSection, $('.sessions-account-titlebar-panel-section-header'));
619
const subscriptionHeading = append(subscriptionHeader, $('div.sessions-account-titlebar-panel-section-title', { id: subscriptionId }));
620
subscriptionHeading.textContent = localize('sessionsAccountMenu.subscription', "Subscription");
621
// Render the dashboard's title header (plan name + manage / CTA actions)
622
// directly into our section header row via the dashboard's public API.
623
const dashboard = this.createCopilotHoverContent({ titleHeaderContainer: subscriptionHeader });
624
append(subscriptionSection, dashboard);
625
} else if (!this.isAccountLoading) {
626
const summary = append(contentSection, $('.sessions-account-titlebar-panel-summary'));
627
summary.textContent = this.lastState.ariaLabel;
628
}
629
630
return panel;
631
}
632
633
private partitionMenuActions(rawActions: IAction[]): { signOut: IAction | undefined; personalize: IAction[]; other: IAction[] } {
634
let signOut: IAction | undefined;
635
const personalizeMap = new Map<string, IAction>();
636
const other: IAction[] = [];
637
638
const pushSeparator = () => {
639
// Collapse runs and skip leading separators so groups whose only
640
// items get filtered (e.g. update.*) don't leave orphans behind.
641
if (other.length === 0 || other[other.length - 1] instanceof Separator) {
642
return;
643
}
644
other.push(new Separator());
645
};
646
647
for (const action of rawActions) {
648
if (action instanceof Separator) {
649
pushSeparator();
650
continue;
651
}
652
if (action.id === SIGN_OUT_ACTION_ID) {
653
signOut = action;
654
continue;
655
}
656
if (PERSONALIZE_ACTION_IDS.includes(action.id)) {
657
personalizeMap.set(action.id, action);
658
continue;
659
}
660
if (action.id.startsWith('update.')) {
661
continue;
662
}
663
if (this.isAccountLoading && action.id === SIGN_IN_ACTION_ID) {
664
continue;
665
}
666
other.push(action);
667
}
668
669
// Trim trailing separator left after filtering.
670
if (other.length > 0 && other[other.length - 1] instanceof Separator) {
671
other.pop();
672
}
673
674
// Preserve canonical personalize order.
675
const personalize = PERSONALIZE_ACTION_IDS
676
.map(id => personalizeMap.get(id))
677
.filter((a): a is IAction => !!a);
678
679
return { signOut, personalize, other };
680
}
681
682
private createPanelButton(
683
parent: HTMLElement,
684
action: IAction,
685
panelStore: DisposableStore,
686
options: { classNames: readonly string[]; icon?: ThemeIcon; includeLabel?: boolean; checked?: boolean },
687
): HTMLButtonElement {
688
const button = append(parent, $('button', { type: 'button' })) as HTMLButtonElement;
689
button.classList.add(...options.classNames);
690
button.disabled = !action.enabled;
691
button.setAttribute('aria-label', action.tooltip || action.label);
692
if (options.checked) {
693
button.classList.add('checked');
694
}
695
696
if (options.icon && options.includeLabel) {
697
const iconElement = append(button, $('span.sessions-account-titlebar-panel-action-icon'));
698
iconElement.classList.add(...ThemeIcon.asClassNameArray(options.icon));
699
const labelElement = append(button, $('span.sessions-account-titlebar-panel-action-label'));
700
append(labelElement, ...renderLabelWithIcons(action.label));
701
} else if (options.icon) {
702
button.title = action.tooltip || action.label;
703
button.classList.add(...ThemeIcon.asClassNameArray(options.icon));
704
} else {
705
append(button, ...renderLabelWithIcons(action.label));
706
}
707
708
panelStore.add(addDisposableListener(button, EventType.CLICK, async event => {
709
event.preventDefault();
710
event.stopPropagation();
711
this.hoverService.hideHover(true);
712
this.clickPanelDisposable.clear();
713
await Promise.resolve(action.run());
714
}));
715
716
return button;
717
}
718
719
private getPanelHeaderLabel(): string {
720
if (this.accountName) {
721
return this.accountName;
722
}
723
724
if (this.isAccountLoading) {
725
return localize('loadingAccountHeader', "Loading Account...");
726
}
727
728
return localize('accountMenuHeaderFallback', "Account");
729
}
730
731
private getHeaderActionIcon(action: IAction): ThemeIcon {
732
switch (action.id) {
733
case 'workbench.action.selectTheme':
734
return Codicon.symbolColor;
735
case 'workbench.action.openSettings':
736
return Codicon.settingsGear;
737
case SIGN_OUT_ACTION_ID:
738
return Codicon.signOut;
739
default:
740
return Codicon.circleLargeFilled;
741
}
742
}
743
744
private getPersonalizeActionIcon(action: IAction): ThemeIcon {
745
switch (action.id) {
746
case 'workbench.action.openSettings':
747
return Codicon.settingsGear;
748
case 'workbench.action.openGlobalKeybindings':
749
return Codicon.keyboard;
750
case 'workbench.action.selectTheme':
751
return Codicon.symbolColor;
752
default:
753
return Codicon.circleLargeFilled;
754
}
755
}
756
757
private shouldShowCopilotDashboardHover(): boolean {
758
return !this.chatEntitlementService.sentiment.hidden && !!this.accountName;
759
}
760
761
private createCopilotHoverContent(extraOptions?: Partial<IChatStatusDashboardOptions>): HTMLElement {
762
const store = new DisposableStore();
763
this.copilotDashboardStore.value = store;
764
const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, {
765
disableInlineSuggestionsSettings: true,
766
disableModelSelection: true,
767
disableProviderOptions: true,
768
disableCompletionsSnooze: true,
769
disableQuickSettingsCollapsible: true,
770
disableContributedSectionsCollapsible: true,
771
...extraOptions,
772
});
773
774
store.add(disposableWindowInterval(mainWindow, () => {
775
if (!dashboardElement.isConnected) {
776
store.dispose();
777
}
778
}, 2000));
779
780
return dashboardElement;
781
}
782
}
783
784
class TitleBarUpdateWidget extends BaseActionViewItem {
785
786
private container: HTMLElement | undefined;
787
private labelElement: HTMLElement | undefined;
788
private readonly updateHoverWidget: UpdateHoverWidget;
789
private readonly hoverAttachment = this._register(new MutableDisposable());
790
791
constructor(
792
action: IAction,
793
options: IBaseActionViewItemOptions | undefined,
794
@IUpdateService private readonly updateService: IUpdateService,
795
@IHoverService private readonly hoverService: IHoverService,
796
@IProductService private readonly productService: IProductService,
797
@IOpenerService private readonly openerService: IOpenerService,
798
@IDialogService private readonly dialogService: IDialogService,
799
@IHostService private readonly hostService: IHostService,
800
) {
801
super(undefined, action, options);
802
this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService);
803
this._register(this.updateService.onStateChange(() => this.renderState()));
804
}
805
806
override render(container: HTMLElement): void {
807
super.render(container);
808
809
this.container = container;
810
container.classList.add('sessions-update-titlebar-widget');
811
container.setAttribute('role', 'button');
812
813
this.labelElement = append(container, $('span.sessions-update-titlebar-widget-label'));
814
this.hoverAttachment.value = this.updateHoverWidget.attachTo(container);
815
816
this.renderState();
817
}
818
819
override onClick(): void {
820
const state = this.updateService.state;
821
if (shouldHideSessionsTitleBarUpdateWidget(state.type) || isBusySessionsTitleBarUpdateWidget(state.type)) {
822
return;
823
}
824
825
void runSessionsUpdateAction(
826
state,
827
this.updateService,
828
this.openerService,
829
this.productService,
830
this.dialogService,
831
this.hostService,
832
);
833
}
834
835
private renderState(): void {
836
if (!this.container || !this.labelElement) {
837
return;
838
}
839
840
const state = this.updateService.state;
841
const hidden = shouldHideSessionsTitleBarUpdateWidget(state.type);
842
const busy = isBusySessionsTitleBarUpdateWidget(state.type);
843
const primary = isPrimarySessionsTitleBarUpdateWidget(state.type);
844
845
this.container.classList.toggle('hidden', hidden);
846
this.container.classList.toggle('disabled', busy);
847
this.container.classList.toggle('primary-state', primary);
848
this.container.classList.toggle('busy-state', busy);
849
850
if (hidden) {
851
this.container.removeAttribute('aria-label');
852
this.labelElement.textContent = '';
853
return;
854
}
855
856
this.container.setAttribute('aria-label', getSessionsTitleBarUpdateAriaLabel(state));
857
this.labelElement.textContent = getSessionsTitleBarUpdateLabel(state);
858
}
859
}
860
861
// --- Register custom view item --- //
862
863
// Actions registered at module level so Menus.TitleBarRightLayout is non-empty when the
864
// toolbar is first constructed. The run() is a no-op — rendering is handled by the custom
865
// view items registered in AccountWidgetContribution.
866
registerAction2(class extends Action2 {
867
constructor() {
868
super({
869
id: SessionsTitleBarUpdateWidgetAction,
870
title: localize2('agentsUpdateTitleBar', "Agents Update"),
871
menu: {
872
id: Menus.TitleBarRightLayout,
873
group: 'navigation',
874
order: 99,
875
when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()),
876
}
877
});
878
}
879
880
run(): void { }
881
});
882
883
registerAction2(class extends Action2 {
884
constructor() {
885
super({
886
id: SessionsTitleBarAccountWidgetAction,
887
title: localize2('agentsAccountStatusTitleBar', "Agents Account and Status"),
888
menu: {
889
id: Menus.TitleBarRightLayout,
890
group: 'navigation',
891
order: 100,
892
when: IsAuxiliaryWindowContext.toNegated(),
893
}
894
});
895
}
896
897
run(): void { }
898
});
899
900
class AccountWidgetContribution extends Disposable implements IWorkbenchContribution {
901
902
static readonly ID = 'workbench.contrib.sessionsWidget';
903
904
constructor(
905
@IActionViewItemService actionViewItemService: IActionViewItemService,
906
@IInstantiationService instantiationService: IInstantiationService,
907
) {
908
super();
909
910
this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarUpdateWidgetAction, (action, options) => {
911
return instantiationService.createInstance(TitleBarUpdateWidget, action, options);
912
}, undefined));
913
914
this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarAccountWidgetAction, (action, options) => {
915
return instantiationService.createInstance(TitleBarAccountWidget, action, options);
916
}, undefined));
917
}
918
}
919
920
registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.BlockRestore);
921
922
// --- Chat Dashboard Service (real implementation for mobile account sheet) --- //
923
924
class ChatDashboardServiceImpl implements IChatDashboardService {
925
readonly _serviceBrand: undefined;
926
927
constructor(
928
@IInstantiationService private readonly instantiationService: IInstantiationService,
929
) { }
930
931
createDashboardElement(store: DisposableStore): HTMLElement | undefined {
932
const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, {
933
disableInlineSuggestionsSettings: true,
934
disableModelSelection: true,
935
disableProviderOptions: true,
936
disableCompletionsSnooze: true,
937
});
938
939
store.add(disposableWindowInterval(mainWindow, () => {
940
if (!dashboardElement.isConnected) {
941
store.dispose();
942
}
943
}, 2000));
944
945
return dashboardElement;
946
}
947
}
948
949
registerSingleton(IChatDashboardService, ChatDashboardServiceImpl, InstantiationType.Delayed);
950
951