Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts
13399 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 './mobileChatShell.css';
7
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
8
import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js';
9
import { Emitter, Event } from '../../../../base/common/event.js';
10
import { ThemeIcon } from '../../../../base/common/themables.js';
11
import { Codicon } from '../../../../base/common/codicons.js';
12
import { IAction, Separator } from '../../../../base/common/actions.js';
13
import { localize } from '../../../../nls.js';
14
import { autorun } from '../../../../base/common/observable.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
17
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
18
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
19
import { IMenuService } from '../../../../platform/actions/common/actions.js';
20
import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
21
import { ICommandService } from '../../../../platform/commands/common/commands.js';
22
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
23
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
24
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
25
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
26
import { ISessionFileChange } from '../../../services/sessions/common/session.js';
27
import { IsNewChatSessionContext } from '../../../common/contextkeys.js';
28
import { SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js';
29
import { Menus } from '../../menus.js';
30
import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js';
31
import { getAccountTitleBarState, getAccountProfileImageUrl, getAccountTitleBarBadgeKey, resolveAccountInfo } from '../../accountTitleBarState.js';
32
import { IChatDashboardService } from '../../chatDashboardService.js';
33
import { basename } from '../../../../base/common/resources.js';
34
import { IFileDiffViewData, MOBILE_OPEN_DIFF_VIEW_COMMAND_ID } from './contributions/mobileDiffView.js';
35
36
/**
37
* Mobile titlebar — prepended above the workbench grid on phone viewports
38
* in place of the desktop titlebar.
39
*
40
* Layout (contextual right slot):
41
*
42
* - **In a chat session** → `[toggle sidebar] [session title] [changes pill] [+]`
43
* - **Welcome / new session** → `[toggle sidebar] [host widget | title] [account]`
44
*
45
* The center slot switches content based on whether the sessions welcome
46
* (home/empty) screen is visible:
47
*
48
* - **Welcome hidden** → shows the active session title (live, from
49
* {@link ISessionsManagementService.activeSession}).
50
* - **Welcome visible** → shows whatever is contributed to the
51
* {@link Menus.MobileTitleBarCenter} menu. On web, the host filter
52
* contribution appends its host dropdown + connection button there.
53
*
54
* The switch is driven entirely by the menu: when the toolbar has no
55
* items the title is shown; as soon as it has items the title is hidden
56
* and the toolbar fills the slot.
57
*
58
* The right slot swaps between the new-session (+) button (in a chat)
59
* and the account indicator (on welcome / new session). The account
60
* indicator shows the user's avatar or a person icon with an optional
61
* dot badge for quota/status warnings. Tapping it opens a panel with
62
* account info, copilot status dashboard, and sign-in/sign-out actions.
63
*/
64
export class MobileTitlebarPart extends Disposable {
65
66
readonly element: HTMLElement;
67
68
private readonly sessionTitleElement: HTMLElement;
69
private readonly actionsContainer: HTMLElement;
70
71
private readonly _onDidClickHamburger = this._register(new Emitter<void>());
72
readonly onDidClickHamburger: Event<void> = this._onDidClickHamburger.event;
73
74
private readonly _onDidClickNewSession = this._register(new Emitter<void>());
75
readonly onDidClickNewSession: Event<void> = this._onDidClickNewSession.event;
76
77
private readonly _onDidClickTitle = this._register(new Emitter<void>());
78
readonly onDidClickTitle: Event<void> = this._onDidClickTitle.event;
79
80
// Account indicator state
81
private readonly accountButton: HTMLElement;
82
private readonly accountAvatarElement: HTMLImageElement;
83
private readonly accountIconElement: HTMLElement;
84
private readonly accountBadgeElement: HTMLElement;
85
private accountName: string | undefined;
86
private accountProviderId: string | undefined;
87
private accountProviderLabel: string | undefined;
88
private isAccountLoading = true;
89
private accountRequestCounter = 0;
90
private avatarRequestCounter = 0;
91
private currentAvatarUrl: string | undefined;
92
private loadedAvatarUrl: string | undefined;
93
private isAccountMenuVisible = false;
94
private lastBadgeKey: string | undefined;
95
private dismissedBadgeKey: string | undefined;
96
private readonly accountPanelDisposable = this._register(new MutableDisposable<DisposableStore>());
97
private readonly avatarLoadDisposable = this._register(new MutableDisposable());
98
private readonly copilotDashboardStore = this._register(new MutableDisposable<DisposableStore>());
99
100
// Changes pill state — kept here so the click handler can read the
101
// latest set without re-deriving it on each tap.
102
private latestChanges: readonly ISessionFileChange[] = [];
103
104
constructor(
105
parent: HTMLElement,
106
@IInstantiationService instantiationService: IInstantiationService,
107
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
108
@IContextKeyService private readonly contextKeyService: IContextKeyService,
109
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
110
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
111
@IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService,
112
@IMenuService private readonly menuService: IMenuService,
113
@IChatDashboardService private readonly chatDashboardService: IChatDashboardService,
114
@ICommandService private readonly commandService: ICommandService,
115
@IQuickInputService private readonly quickInputService: IQuickInputService,
116
) {
117
super();
118
119
this.element = document.createElement('div');
120
this.element.className = 'mobile-top-bar';
121
122
// Register DOM removal before appending so that any exception
123
// between this point and the end of the constructor still cleans
124
// up the element via disposal.
125
this._register(toDisposable(() => this.element.remove()));
126
parent.prepend(this.element);
127
128
// Sidebar toggle button. Uses the same icon as the desktop/web
129
// agents-app sidebar toggle and reflects open/closed state via the
130
// SideBarVisibleContext key.
131
const hamburger = append(this.element, $('button.mobile-top-bar-button'));
132
hamburger.setAttribute('aria-label', localize('mobileTopBar.openSessions', "Open sessions"));
133
const hamburgerIcon = append(hamburger, $('span'));
134
const closedIconClasses = ThemeIcon.asClassNameArray(Codicon.layoutSidebarLeftOff);
135
const openIconClasses = ThemeIcon.asClassNameArray(Codicon.layoutSidebarLeft);
136
hamburgerIcon.classList.add(...closedIconClasses);
137
this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire()));
138
139
const sidebarVisibleKeySet = new Set([SideBarVisibleContext.key]);
140
const updateSidebarIcon = () => {
141
const isOpen = !!SideBarVisibleContext.getValue(contextKeyService);
142
hamburgerIcon.classList.remove(...closedIconClasses, ...openIconClasses);
143
hamburgerIcon.classList.add(...(isOpen ? openIconClasses : closedIconClasses));
144
hamburger.setAttribute('aria-label', isOpen
145
? localize('mobileTopBar.closeSessions', "Close sessions")
146
: localize('mobileTopBar.openSessions', "Open sessions"));
147
};
148
updateSidebarIcon();
149
150
// Center slot: title and/or actions container (mutually exclusive)
151
const center = append(this.element, $('div.mobile-top-bar-center'));
152
153
this.sessionTitleElement = append(center, $('button.mobile-session-title'));
154
this.sessionTitleElement.setAttribute('type', 'button');
155
this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session");
156
this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire()));
157
158
this.actionsContainer = append(center, $('div.mobile-top-bar-actions'));
159
160
// Right slot — laid out left-to-right in DOM order. The new-session
161
// (+) button is appended LAST so it always sits at the right edge,
162
// even when the changes pill is visible.
163
164
// Changes pill — shown when in a chat that has produced changes.
165
// Tap → opens a file picker; selecting a file invokes the
166
// `sessions.mobile.openDiffView` command for that file's diff.
167
const changesPill = append(this.element, $('button.mobile-top-bar-button.mobile-changes-pill', { type: 'button' })) as HTMLButtonElement;
168
changesPill.setAttribute('aria-label', localize('mobileTopBar.changes', "View changes"));
169
changesPill.style.display = 'none';
170
const changesIcon = append(changesPill, $('span.mobile-changes-pill-icon'));
171
changesIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple));
172
const changesAddedEl = append(changesPill, $('span.mobile-changes-pill-added'));
173
const changesRemovedEl = append(changesPill, $('span.mobile-changes-pill-removed'));
174
this._register(addDisposableListener(changesPill, EventType.CLICK, () => this.showChangesPicker()));
175
176
// New session button (+) — shown when in a chat, hidden on welcome.
177
// Always rightmost when in a chat.
178
const newSessionButton = append(this.element, $('button.mobile-top-bar-button.mobile-new-session-button'));
179
newSessionButton.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session"));
180
const newSessionIcon = append(newSessionButton, $('span'));
181
newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus));
182
this._register(addDisposableListener(newSessionButton, EventType.CLICK, () => this._onDidClickNewSession.fire()));
183
184
// Account indicator — shown on welcome/new session, hidden in a chat
185
this.accountButton = append(this.element, $('button.mobile-top-bar-button.mobile-account-indicator'));
186
this.accountButton.setAttribute('aria-label', localize('mobileTopBar.account', "Account"));
187
this.accountAvatarElement = append(this.accountButton, $('img.mobile-account-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement;
188
this.accountAvatarElement.decoding = 'async';
189
this.accountAvatarElement.referrerPolicy = 'no-referrer';
190
this.accountIconElement = append(this.accountButton, $('span'));
191
this.accountBadgeElement = append(this.accountButton, $('span.mobile-account-badge'));
192
this._register(addDisposableListener(this.accountButton, EventType.CLICK, () => this.showAccountPanel()));
193
194
// Track account state — listen to multiple sources to catch
195
// updates regardless of service initialization ordering.
196
this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount()));
197
this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount()));
198
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderAccountState()));
199
this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderAccountState()));
200
this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderAccountState()));
201
this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderAccountState()));
202
this.refreshAccount();
203
204
// Keep the title in sync with the active session
205
this._register(autorun(reader => {
206
const session = this.sessionsManagementService.activeSession.read(reader);
207
const title = session?.title.read(reader);
208
this.sessionTitleElement.textContent = title || localize('mobileTopBar.newSession', "New Session");
209
}));
210
211
// Keep the changes pill in sync with the active session's changes.
212
// Hidden when there are no changes (counts are zero and list is empty).
213
const isNewChatRef = { value: !!IsNewChatSessionContext.getValue(contextKeyService) };
214
const renderChangesPill = () => {
215
const changes = this.latestChanges;
216
let added = 0;
217
let removed = 0;
218
for (const c of changes) {
219
added += c.insertions;
220
removed += c.deletions;
221
}
222
const hasChanges = changes.length > 0 && (added > 0 || removed > 0);
223
// Hide on welcome / new-chat — no session changes to view there.
224
const visible = hasChanges && !isNewChatRef.value;
225
changesPill.style.display = visible ? '' : 'none';
226
if (visible) {
227
changesAddedEl.textContent = `+${added}`;
228
changesRemovedEl.textContent = `-${removed}`;
229
changesPill.title = localize('mobileTopBar.changesTooltip', "{0} files changed (+{1} -{2})", changes.length, added, removed);
230
}
231
};
232
this._register(autorun(reader => {
233
const session = this.sessionsManagementService.activeSession.read(reader);
234
this.latestChanges = session?.changes.read(reader) ?? [];
235
renderChangesPill();
236
}));
237
238
// Mount the center toolbar (host filter widget on web welcome, etc.)
239
const toolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this.actionsContainer, Menus.MobileTitleBarCenter, {
240
hiddenItemStrategy: HiddenItemStrategy.NoHide,
241
telemetrySource: 'mobileTitlebar.center',
242
toolbarOptions: { primaryGroup: () => true },
243
}));
244
245
// Switch between title and toolbar based on whether a new (empty)
246
// chat session is active AND whether the toolbar has anything to
247
// show. The latter is important because on desktop/electron or
248
// when no agent hosts are configured the toolbar can be empty —
249
// in that case we keep the title visible.
250
const newChatKeySet = new Set([IsNewChatSessionContext.key]);
251
const updateCenterMode = () => {
252
const isNewChat = !!IsNewChatSessionContext.getValue(contextKeyService);
253
const hasActions = toolbar.getItemsLength() > 0;
254
this.element.classList.toggle('show-actions', isNewChat && hasActions);
255
256
// Right slot: swap between [+] (in-chat) and [account] (welcome)
257
newSessionButton.style.display = isNewChat ? 'none' : '';
258
this.accountButton.style.display = isNewChat ? '' : 'none';
259
260
// Changes pill follows the in-chat state — hidden on welcome.
261
isNewChatRef.value = isNewChat;
262
renderChangesPill();
263
};
264
updateCenterMode();
265
this._register(contextKeyService.onDidChangeContext(e => {
266
if (e.affectsSome(newChatKeySet)) {
267
updateCenterMode();
268
}
269
if (e.affectsSome(sidebarVisibleKeySet)) {
270
updateSidebarIcon();
271
}
272
}));
273
this._register(toolbar.onDidChangeMenuItems(() => updateCenterMode()));
274
}
275
276
/**
277
* Explicitly set the title shown in the center slot. Called only when
278
* overriding the live session title (tests, placeholders). The live
279
* subscription will overwrite this on the next session change.
280
*/
281
setTitle(title: string): void {
282
this.sessionTitleElement.textContent = title;
283
}
284
285
// --- Changes Pill --- //
286
287
/**
288
* Open a quick pick listing the files changed in the active session.
289
* Selecting one invokes {@link MOBILE_OPEN_DIFF_VIEW_COMMAND_ID} with
290
* the corresponding {@link IFileDiffViewData}.
291
*/
292
private showChangesPicker(): void {
293
const changes = this.latestChanges;
294
if (!changes.length) {
295
return;
296
}
297
298
type Item = IQuickPickItem & { readonly diff: IFileDiffViewData };
299
const items: Item[] = changes.map(change => {
300
// IChatSessionFileChange2 carries `uri` (and may also have `modifiedUri`);
301
// the legacy IChatSessionFileChange only has `modifiedUri`. The discriminator
302
// is the presence of `uri` (required on the v2 shape, absent on v1).
303
const v2 = (change as { uri?: URI }).uri;
304
const modifiedURI = v2 ? (change.modifiedUri ?? v2) : change.modifiedUri!;
305
// `originalURI` may legitimately be undefined for newly-added files;
306
// MobileDiffView treats that as an empty original (all-added diff).
307
// Do NOT fall back to modifiedURI here — that would self-diff and
308
// render "No changes in this file." for added files.
309
const originalURI = change.originalUri;
310
const added = change.insertions;
311
const removed = change.deletions;
312
return {
313
label: basename(modifiedURI),
314
description: `+${added} -${removed}`,
315
detail: modifiedURI.path,
316
diff: {
317
originalURI,
318
modifiedURI,
319
identical: added === 0 && removed === 0,
320
added,
321
removed,
322
},
323
};
324
});
325
326
const picker = this.quickInputService.createQuickPick<Item>();
327
picker.title = localize('mobileTopBar.changesPickerTitle', "Session Changes");
328
picker.placeholder = localize('mobileTopBar.changesPickerPlaceholder', "Select a file to view its diff");
329
picker.matchOnDescription = true;
330
picker.items = items;
331
const store = new DisposableStore();
332
store.add(picker.onDidAccept(() => {
333
const selected = picker.selectedItems[0];
334
if (selected) {
335
this.commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, selected.diff);
336
}
337
picker.hide();
338
}));
339
store.add(picker.onDidHide(() => {
340
store.dispose();
341
picker.dispose();
342
}));
343
picker.show();
344
}
345
346
// --- Account Indicator --- //
347
348
private async refreshAccount(): Promise<void> {
349
const requestId = ++this.accountRequestCounter;
350
this.isAccountLoading = true;
351
this.renderAccountState();
352
353
const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService);
354
if (requestId !== this.accountRequestCounter) {
355
return;
356
}
357
358
this.accountName = info?.accountName;
359
this.accountProviderId = info?.accountProviderId;
360
this.accountProviderLabel = info?.accountProviderLabel;
361
this.isAccountLoading = false;
362
this.refreshAvatar();
363
this.renderAccountState();
364
}
365
366
private renderAccountState(): void {
367
// When we have a session from the auth service but the entitlement
368
// service hasn't resolved yet (still Unknown), treat it as the
369
// account being available rather than signed out. This avoids
370
// showing "Sign In" right after the walkthrough completes.
371
const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown
372
? ChatEntitlement.Unresolved
373
: this.chatEntitlementService.entitlement;
374
375
const state = getAccountTitleBarState({
376
isAccountLoading: this.isAccountLoading,
377
accountName: this.accountName,
378
accountProviderLabel: this.accountProviderLabel,
379
entitlement,
380
sentiment: this.chatEntitlementService.sentiment,
381
quotas: this.chatEntitlementService.quotas,
382
});
383
384
// Avatar
385
const hasAvatar = !!this.loadedAvatarUrl && !this.isAccountLoading;
386
this.accountAvatarElement.classList.toggle('visible', hasAvatar);
387
if (hasAvatar && this.accountAvatarElement.src !== this.loadedAvatarUrl) {
388
this.accountAvatarElement.src = this.loadedAvatarUrl!;
389
} else if (!hasAvatar) {
390
this.accountAvatarElement.removeAttribute('src');
391
}
392
393
// Codicon fallback
394
const titleBarIcon = state.dotBadge ? Codicon.account : state.icon;
395
this.accountIconElement.className = ThemeIcon.asClassName(titleBarIcon);
396
this.accountIconElement.classList.toggle('hidden', hasAvatar);
397
398
// Dot badge
399
const badgeKey = getAccountTitleBarBadgeKey(state);
400
if (badgeKey !== this.lastBadgeKey) {
401
this.lastBadgeKey = badgeKey;
402
this.dismissedBadgeKey = undefined;
403
}
404
const showBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey;
405
this.accountBadgeElement.style.display = showBadge ? '' : 'none';
406
this.accountBadgeElement.classList.toggle('dot-badge-warning', showBadge && state.dotBadge === 'warning');
407
this.accountBadgeElement.classList.toggle('dot-badge-error', showBadge && state.dotBadge === 'error');
408
409
// ARIA
410
this.accountButton.setAttribute('aria-label', state.ariaLabel);
411
}
412
413
private refreshAvatar(): void {
414
const avatarUrl = getAccountProfileImageUrl(this.accountProviderId, this.accountName);
415
if (avatarUrl === this.currentAvatarUrl) {
416
return;
417
}
418
419
this.currentAvatarUrl = avatarUrl;
420
this.loadedAvatarUrl = undefined;
421
this.avatarLoadDisposable.clear();
422
const requestId = ++this.avatarRequestCounter;
423
424
if (!avatarUrl) {
425
this.renderAccountState();
426
return;
427
}
428
429
const image = new Image();
430
image.referrerPolicy = 'no-referrer';
431
const clearHandlers = () => { image.onload = null; image.onerror = null; };
432
image.onload = () => {
433
if (requestId !== this.avatarRequestCounter) { return; }
434
this.loadedAvatarUrl = avatarUrl;
435
this.renderAccountState();
436
clearHandlers();
437
};
438
image.onerror = () => {
439
if (requestId !== this.avatarRequestCounter) { return; }
440
this.loadedAvatarUrl = undefined;
441
this.renderAccountState();
442
clearHandlers();
443
};
444
this.avatarLoadDisposable.value = toDisposable(() => { clearHandlers(); image.src = ''; });
445
image.src = avatarUrl;
446
}
447
448
// --- Account Sheet --- //
449
450
private showAccountPanel(): void {
451
if (this.isAccountMenuVisible) {
452
this.accountPanelDisposable.clear();
453
return;
454
}
455
456
this.accountPanelDisposable.clear();
457
458
const panelStore = new DisposableStore();
459
this.accountPanelDisposable.value = panelStore;
460
461
const badgeKey = getAccountTitleBarBadgeKey(getAccountTitleBarState({
462
isAccountLoading: this.isAccountLoading,
463
accountName: this.accountName,
464
accountProviderLabel: this.accountProviderLabel,
465
entitlement: this.chatEntitlementService.entitlement,
466
sentiment: this.chatEntitlementService.sentiment,
467
quotas: this.chatEntitlementService.quotas,
468
}));
469
if (badgeKey) {
470
this.dismissedBadgeKey = badgeKey;
471
}
472
473
this.isAccountMenuVisible = true;
474
this.renderAccountState();
475
panelStore.add({
476
dispose: () => {
477
this.isAccountMenuVisible = false;
478
this.copilotDashboardStore.clear();
479
this.renderAccountState();
480
}
481
});
482
483
const closeSheet = () => this.accountPanelDisposable.clear();
484
485
// Full-screen sheet inside the workbench container
486
const workbenchContainer = this.element.parentElement!;
487
const sheet = append(workbenchContainer, $('div.mobile-account-sheet'));
488
panelStore.add(toDisposable(() => sheet.remove()));
489
490
// Header: title + close button
491
const header = append(sheet, $('div.mobile-account-sheet-header'));
492
const headerTitle = append(header, $('h2.mobile-account-sheet-title'));
493
headerTitle.textContent = localize('mobileAccount.title', "Account");
494
const closeButton = append(header, $('button.mobile-account-sheet-close', { type: 'button' })) as HTMLButtonElement;
495
closeButton.setAttribute('aria-label', localize('mobileAccount.close', "Close"));
496
append(closeButton, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.close));
497
panelStore.add(addDisposableListener(closeButton, EventType.CLICK, closeSheet));
498
499
// Scrollable content
500
const content = append(sheet, $('div.mobile-account-sheet-content'));
501
502
// Profile section
503
const profile = append(content, $('div.mobile-account-sheet-profile'));
504
if (this.loadedAvatarUrl) {
505
const avatar = append(profile, $('img.mobile-account-sheet-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement;
506
avatar.src = this.loadedAvatarUrl;
507
avatar.referrerPolicy = 'no-referrer';
508
avatar.decoding = 'async';
509
} else {
510
const avatarPlaceholder = append(profile, $('div.mobile-account-sheet-avatar-placeholder'));
511
append(avatarPlaceholder, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.account));
512
}
513
const profileInfo = append(profile, $('div.mobile-account-sheet-profile-info'));
514
if (this.isAccountLoading) {
515
append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.loading', "Loading...");
516
} else if (this.accountName) {
517
append(profileInfo, $('div.mobile-account-sheet-name')).textContent = this.accountName;
518
if (this.accountProviderLabel) {
519
append(profileInfo, $('div.mobile-account-sheet-provider')).textContent = this.accountProviderLabel;
520
}
521
} else {
522
append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.signedOut', "Not signed in");
523
}
524
525
// Copilot status dashboard — only when signed in AND entitlements
526
// have resolved. When entitlement is Unknown or Available (setup
527
// pending), the dashboard shows a "Set up Copilot" prompt that
528
// doesn't apply in the agents app.
529
const entitlement = this.chatEntitlementService.entitlement;
530
const showDashboard = !this.chatEntitlementService.sentiment.hidden
531
&& !!this.accountName
532
&& entitlement !== ChatEntitlement.Unknown
533
&& entitlement !== ChatEntitlement.Available;
534
if (showDashboard) {
535
const dashboardSection = append(content, $('div.mobile-account-sheet-section'));
536
const store = new DisposableStore();
537
this.copilotDashboardStore.value = store;
538
const dashboardElement = this.chatDashboardService.createDashboardElement(store);
539
if (dashboardElement) {
540
append(dashboardSection, dashboardElement);
541
}
542
}
543
544
// Actions list
545
const actionsSection = append(content, $('div.mobile-account-sheet-actions'));
546
const allActions = this.getSheetActions();
547
for (const action of allActions) {
548
if (action instanceof Separator) {
549
append(actionsSection, $('div.mobile-account-sheet-separator'));
550
continue;
551
}
552
const row = append(actionsSection, $('button.mobile-account-sheet-action', { type: 'button' })) as HTMLButtonElement;
553
row.disabled = !action.enabled;
554
row.setAttribute('aria-label', action.tooltip || action.label);
555
const icon = this.getActionIcon(action);
556
if (icon) {
557
append(row, $('span.mobile-account-sheet-action-icon')).classList.add(...ThemeIcon.asClassNameArray(icon));
558
}
559
append(row, $('span.mobile-account-sheet-action-label')).textContent = action.label;
560
panelStore.add(addDisposableListener(row, EventType.CLICK, async event => {
561
event.preventDefault();
562
event.stopPropagation();
563
closeSheet();
564
await Promise.resolve(action.run());
565
}));
566
}
567
}
568
569
private getSheetActions(): IAction[] {
570
const menu = this.menuService.createMenu(Menus.AccountMenu, this.contextKeyService);
571
const rawActions: IAction[] = [];
572
fillInActionBarActions(menu.getActions(), rawActions);
573
menu.dispose();
574
return rawActions.filter(action => {
575
if (action instanceof Separator) {
576
return true;
577
}
578
if (this.isAccountLoading && action.id === 'workbench.action.agenticSignIn') {
579
return false;
580
}
581
return !action.id.startsWith('update.');
582
});
583
}
584
585
private getActionIcon(action: IAction): ThemeIcon | undefined {
586
switch (action.id) {
587
case 'workbench.action.openSettings': return Codicon.settingsGear;
588
case 'workbench.action.agenticSignOut': return Codicon.signOut;
589
case 'workbench.action.agenticSignIn': return Codicon.signIn;
590
default: return undefined;
591
}
592
}
593
}
594
595