Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.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 './media/sessionsWalkthrough.css';
7
import { disposableTimeout } from '../../../../base/common/async.js';
8
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
9
import { $, addDisposableGenericMouseDownListener, append, EventType, addDisposableListener, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js';
10
import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
11
import { localize } from '../../../../nls.js';
12
import { FileAccess } from '../../../../base/common/network.js';
13
import { IProductOnboardingTheme } from '../../../../base/common/product.js';
14
import { ICommandService } from '../../../../platform/commands/common/commands.js';
15
import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';
16
import { ILogService } from '../../../../platform/log/common/log.js';
17
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
18
import { IProductService } from '../../../../platform/product/common/productService.js';
19
import { isWeb } from '../../../../base/common/platform.js';
20
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
21
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
22
import { URI } from '../../../../base/common/uri.js';
23
import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
24
import { ChatSetupStrategy } from '../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.js';
25
import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js';
26
import { IWorkbenchThemeService } from '../../../../workbench/services/themes/common/workbenchThemeService.js';
27
import { IThemeImporterService } from '../../../services/vscode/common/themeImporter.js';
28
29
export type WalkthroughOutcome = 'completed' | 'dismissed';
30
31
const fadeDuration = 200;
32
const resetMessageDuration = 2000;
33
const dismissDuration = 250;
34
const fallbackChatAgentLinks = {
35
termsStatementUrl: 'https://aka.ms/github-copilot-terms-statement',
36
privacyStatementUrl: 'https://aka.ms/github-copilot-privacy-statement',
37
publicCodeMatchesUrl: 'https://aka.ms/github-copilot-match-public-code',
38
manageSettingsUrl: 'https://aka.ms/github-copilot-settings'
39
};
40
41
/**
42
* Sign-in onboarding overlay:
43
* - Sign in via GitHub / Google / Apple
44
*/
45
export class SessionsWalkthroughOverlay extends Disposable {
46
47
private readonly overlay: HTMLElement;
48
private readonly card: HTMLElement;
49
private readonly contentContainer: HTMLElement;
50
private readonly footerContainer: HTMLElement;
51
private readonly disclaimerElement: HTMLElement;
52
private readonly disclaimerLinks: readonly HTMLAnchorElement[];
53
private readonly stepDisposables = this._register(new MutableDisposable<DisposableStore>());
54
private readonly previouslyFocusedElement: HTMLElement | undefined;
55
private currentFocusableElements: readonly HTMLElement[] = [];
56
private _resolveOutcome!: (outcome: WalkthroughOutcome) => void;
57
private _outcomeResolved = false;
58
private _isShowingWelcome = false;
59
private _isShowingSignIn = false;
60
private _isShowingThemeStep = false;
61
62
/**
63
* Whether the overlay is currently displaying the signed-in welcome
64
* greeting (as opposed to the sign-in provider buttons). When `true`,
65
* external callers should **not** auto-dismiss the overlay — the user
66
* is expected to click "Get Started" to proceed.
67
*/
68
get isShowingWelcome(): boolean { return this._isShowingWelcome; }
69
70
/**
71
* Whether the overlay is currently displaying the sign-in buttons.
72
* Only `true` after the sign-in screen has been fully rendered —
73
* deliberately `false` during the loading phase so that external
74
* account resolution (e.g. VS Code signing in) cannot auto-dismiss
75
* the overlay before the user has had a chance to interact.
76
*/
77
get isShowingSignIn(): boolean { return this._isShowingSignIn; }
78
79
/**
80
* Transition to the theme selection step. Called by external code
81
* (e.g. the contribution) when the user signs in while the sign-in
82
* screen is visible, so the user still gets to pick a theme before
83
* the overlay dismisses.
84
*/
85
showThemeStep(): void {
86
this._isShowingSignIn = false;
87
this._renderThemeStep();
88
}
89
90
/** Resolves when the user completes or dismisses the walkthrough. */
91
readonly outcome: Promise<WalkthroughOutcome> = new Promise(resolve => { this._resolveOutcome = resolve; });
92
93
constructor(
94
container: HTMLElement,
95
private readonly _isFirstLaunch: boolean,
96
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
97
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
98
@ICommandService private readonly commandService: ICommandService,
99
@IExtensionService private readonly extensionService: IExtensionService,
100
@IOpenerService private readonly openerService: IOpenerService,
101
@IProductService private readonly productService: IProductService,
102
@IThemeImporterService private readonly themeImporterService: IThemeImporterService,
103
@IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService,
104
@ILogService private readonly logService: ILogService,
105
) {
106
super();
107
108
const activeElement = getActiveElement();
109
this.previouslyFocusedElement = isHTMLElement(activeElement) ? activeElement : undefined;
110
111
this.overlay = append(container, $('.sessions-walkthrough-overlay'));
112
this.overlay.setAttribute('role', 'dialog');
113
this.overlay.setAttribute('aria-modal', 'true');
114
this.overlay.setAttribute('aria-label', localize('walkthrough.aria', "Agents onboarding walkthrough"));
115
this._register(toDisposable(() => this.overlay.remove()));
116
this._register(addDisposableListener(this.overlay, EventType.KEY_DOWN, (e: KeyboardEvent) => {
117
if (e.key === 'Escape') {
118
if (this._isShowingThemeStep) {
119
// Remove the theme setting to reset to default
120
this.themeService.setColorTheme(undefined, ConfigurationTarget.USER);
121
this._isShowingWelcome = false;
122
this._isShowingThemeStep = false;
123
this.complete();
124
}
125
e.preventDefault();
126
e.stopPropagation();
127
return;
128
}
129
130
if (e.key === 'Tab') {
131
this._trapFocus(e);
132
}
133
}));
134
this._register(addDisposableGenericMouseDownListener(this.overlay, e => {
135
if (e.target === this.overlay) {
136
e.preventDefault();
137
e.stopPropagation();
138
}
139
}));
140
141
this.card = append(this.overlay, $('.sessions-walkthrough-card'));
142
143
// Scrollable content area
144
this.contentContainer = append(this.card, $('.sessions-walkthrough-content'));
145
146
// Fixed footer
147
this.footerContainer = append(this.card, $('.sessions-walkthrough-footer'));
148
const disclaimer = this._createDisclaimer();
149
this.disclaimerElement = disclaimer.element;
150
this.disclaimerLinks = disclaimer.links;
151
152
// Set synchronously so the autorun in the contribution doesn't
153
// auto-dismiss before the async _renderSignIn completes.
154
// On first launch, optimistically assume signed in — the welcome
155
// screen renders the same regardless, and we update before painting.
156
if (this._isFirstLaunch) {
157
this._isShowingWelcome = true;
158
}
159
160
if (this._isFirstLaunch) {
161
// First launch: render a loading state while the default account resolves.
162
// Reading `currentDefaultAccount` synchronously here would always return null
163
// and cause us to render the sign-in screen for users who are actually signed in.
164
this._renderLoading();
165
this.defaultAccountService.getDefaultAccount().then(() => {
166
if (this._outcomeResolved) {
167
return;
168
}
169
this._isShowingWelcome = this._isSignedIn();
170
this._renderSignIn();
171
});
172
} else {
173
// Sign-out scenario (returning user who is now signed out): account is
174
// already known to be null, so render the sign-in screen immediately.
175
this._isShowingWelcome = false;
176
this._renderSignIn();
177
}
178
}
179
180
/**
181
* Renders a centered animated agents icon as the loading state.
182
* Used while the default account is being resolved on startup, before
183
* the welcome content is rendered.
184
*/
185
private _renderLoading(): void {
186
this.contentContainer.textContent = '';
187
this.footerContainer.textContent = '';
188
this.disclaimerElement.classList.add('hidden');
189
190
const loadingIndicator = append(this.contentContainer, $('div.sessions-walkthrough-loading-indicator')) as HTMLElement;
191
loadingIndicator.setAttribute('role', 'status');
192
loadingIndicator.setAttribute('aria-busy', 'true');
193
loadingIndicator.setAttribute('aria-label', localize('walkthrough.loading', "Loading"));
194
append(loadingIndicator, $('div.sessions-walkthrough-logo.sessions-walkthrough-loading-icon'));
195
}
196
197
// ------------------------------------------------------------------
198
// Sign In
199
200
private _renderSignIn(): void {
201
const stepDisposables = this.stepDisposables.value = new DisposableStore();
202
203
this.contentContainer.textContent = '';
204
this.footerContainer.textContent = '';
205
this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0);
206
207
const productName = this.productService.nameLong;
208
209
// Horizontal layout: icon left, text + buttons right
210
const layout = append(this.contentContainer, $('.sessions-walkthrough-hero'));
211
212
append(layout, $('div.sessions-walkthrough-logo'));
213
214
const right = append(layout, $('.sessions-walkthrough-hero-text'));
215
216
// First time + signed in → welcome greeting with "Get Started"
217
if (this._isFirstLaunch && this._isSignedIn()) {
218
this._renderWelcome(stepDisposables, right, productName);
219
return;
220
}
221
222
// Always show the welcome title/subtitle with sign-in buttons,
223
// whether it's the first launch or a returning user who is signed out.
224
const titleEl = append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName)));
225
const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you.")));
226
append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!")));
227
228
this._renderSignInButtons(stepDisposables, right, titleEl, subtitleEl);
229
}
230
231
private _renderSignInButtons(stepDisposables: DisposableStore, right: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement): void {
232
this._isShowingSignIn = true;
233
const signInActions = append(right, $('.sessions-walkthrough-sign-in-actions'));
234
const providerRow = append(signInActions, $('.sessions-walkthrough-providers-row'));
235
236
const githubBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-primary.provider-github')) as HTMLButtonElement;
237
append(githubBtn, $('span.sessions-walkthrough-provider-label', undefined, localize('walkthrough.signin.github', "Sign in with GitHub")));
238
239
// Desktop-only provider buttons
240
let providerButtons: HTMLButtonElement[];
241
if (isWeb) {
242
providerButtons = [githubBtn];
243
} else {
244
const googleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-google')) as HTMLButtonElement;
245
googleBtn.setAttribute('aria-label', localize('walkthrough.signin.google', "Continue with Google"));
246
googleBtn.title = localize('walkthrough.signin.google', "Continue with Google");
247
248
const appleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-apple')) as HTMLButtonElement;
249
appleBtn.setAttribute('aria-label', localize('walkthrough.signin.apple', "Continue with Apple"));
250
appleBtn.title = localize('walkthrough.signin.apple', "Continue with Apple");
251
252
const enterpriseProviderName = this.productService.defaultChatAgent?.provider?.enterprise?.name || 'GHE';
253
const enterpriseBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-compact.provider-enterprise')) as HTMLButtonElement;
254
enterpriseBtn.setAttribute('aria-label', localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName));
255
enterpriseBtn.title = localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName);
256
append(enterpriseBtn, $('span.sessions-walkthrough-provider-label', undefined, enterpriseProviderName));
257
258
providerButtons = [githubBtn, googleBtn, appleBtn, enterpriseBtn];
259
}
260
261
// Error feedback below providers
262
const errorContainer = append(this.footerContainer, $('p.sessions-walkthrough-error'));
263
errorContainer.style.display = 'none';
264
265
// Focus the first provider button so keyboard users can interact immediately
266
disposableTimeout(() => {
267
if (this.overlay.isConnected && !githubBtn.disabled) {
268
githubBtn.focus();
269
}
270
}, 0, stepDisposables);
271
272
this.currentFocusableElements = [...providerButtons, ...this.disclaimerLinks];
273
274
if (isWeb) {
275
// Web: GitHub button uses IAuthenticationService with product scopes
276
stepDisposables.add(addDisposableListener(githubBtn, EventType.CLICK, () => this._runSignInWeb(
277
providerButtons,
278
errorContainer,
279
titleEl,
280
subtitleEl,
281
signInActions
282
)));
283
} else {
284
// Desktop: each button uses a different ChatSetupStrategy
285
const providerStrategies = [
286
ChatSetupStrategy.SetupWithoutEnterpriseProvider,
287
ChatSetupStrategy.SetupWithGoogleProvider,
288
ChatSetupStrategy.SetupWithAppleProvider,
289
ChatSetupStrategy.SetupWithEnterpriseProvider,
290
];
291
for (let i = 0; i < providerButtons.length; i++) {
292
const strategy = providerStrategies[i];
293
stepDisposables.add(addDisposableListener(providerButtons[i], EventType.CLICK, () => this._runSignIn(
294
providerButtons,
295
errorContainer,
296
strategy,
297
titleEl,
298
subtitleEl,
299
signInActions
300
)));
301
}
302
}
303
}
304
305
// ------------------------------------------------------------------
306
// Welcome (first launch + signed in)
307
308
private _renderWelcome(stepDisposables: DisposableStore, right: HTMLElement, productName: string): void {
309
this._isShowingWelcome = true;
310
this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0);
311
312
append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName)));
313
append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you.")));
314
append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!")));
315
316
const actions = append(right, $('.sessions-walkthrough-welcome-actions'));
317
const getStartedBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement;
318
getStartedBtn.textContent = localize('walkthrough.welcome.getStarted', "Get Started");
319
stepDisposables.add(addDisposableListener(getStartedBtn, EventType.CLICK, () => {
320
this._isShowingWelcome = false;
321
this._renderThemeStep();
322
}));
323
324
this.currentFocusableElements = [getStartedBtn, ...this.disclaimerLinks];
325
326
disposableTimeout(() => {
327
if (this.overlay.isConnected) {
328
getStartedBtn.focus();
329
}
330
}, 0, stepDisposables);
331
}
332
333
private _isSignedIn(): boolean {
334
return this.defaultAccountService.currentDefaultAccount !== null;
335
}
336
337
// ------------------------------------------------------------------
338
// Theme Step
339
340
private _renderThemeStep(): void {
341
const stepDisposables = this.stepDisposables.value = new DisposableStore();
342
this._isShowingWelcome = true;
343
this._isShowingThemeStep = true;
344
345
// Start resolving the parent VS Code theme during the fade-out
346
const parentThemePromise = !isWeb
347
? this.themeImporterService.getVSCodeTheme()
348
: Promise.resolve(undefined);
349
350
// Fade out current content, then render theme step
351
this.contentContainer.classList.add('sessions-walkthrough-fade-out');
352
stepDisposables.add(disposableTimeout(async () => {
353
if (!this.overlay.isConnected) {
354
return;
355
}
356
const parentTheme = await parentThemePromise;
357
if (!this.overlay.isConnected) {
358
return;
359
}
360
// Only show the VS Code theme option if the parent theme is different from the 4 onboarding themes
361
const allOnboardingThemes = this.productService.onboardingThemes ?? [];
362
const shownThemes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized'));
363
const parentThemeSettingsId = shownThemes.some(t => t.themeId === parentTheme) ? undefined : parentTheme;
364
this.contentContainer.classList.remove('sessions-walkthrough-fade-out');
365
this._renderThemeStepContent(stepDisposables, parentThemeSettingsId);
366
}, fadeDuration));
367
}
368
369
private _renderThemeStepContent(stepDisposables: DisposableStore, parentThemeSettingsId: string | undefined): void {
370
this.contentContainer.textContent = '';
371
this.footerContainer.textContent = '';
372
this.disclaimerElement.classList.add('hidden');
373
374
// Header
375
const header = append(this.contentContainer, $('.sessions-walkthrough-theme-header'));
376
append(header, $('h2', undefined, localize('walkthrough.theme.title', "Choose Your Theme")));
377
append(header, $('p', undefined, localize('walkthrough.theme.subtitle', "Pick a color theme to make it yours. You can always change it later.")));
378
379
// Build theme list — exclude solarized variants for the base set
380
const allOnboardingThemes = this.productService.onboardingThemes ?? [];
381
const themes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized'));
382
383
const themeGrid = append(this.contentContainer, $('.sessions-walkthrough-theme-grid'));
384
themeGrid.setAttribute('role', 'radiogroup');
385
themeGrid.setAttribute('aria-label', localize('walkthrough.theme.ariaLabel', "Choose a color theme"));
386
387
// Pre-select the onboarding theme matching the current theme, or fall back to first
388
const currentTheme = this.themeService.getColorTheme();
389
let selectedThemeId = themes.find(t => t.themeId === currentTheme.settingsId)?.id ?? themes[0]?.id;
390
391
const themeCards: HTMLElement[] = [];
392
let vscodeThemeBtn: HTMLElement | undefined;
393
let isVSCodeThemeSelected = false;
394
for (const theme of themes) {
395
const card = this._createThemeCard(stepDisposables, themeGrid, theme, themeCards, selectedThemeId, id => {
396
selectedThemeId = id;
397
isVSCodeThemeSelected = false;
398
if (vscodeThemeBtn) {
399
vscodeThemeBtn.classList.remove('selected');
400
vscodeThemeBtn.setAttribute('aria-checked', 'false');
401
}
402
});
403
themeCards.push(card);
404
}
405
406
// Show a VS Code theme option as a radio-style button inside the radiogroup
407
if (parentThemeSettingsId) {
408
const parentName = this.productService.embedded?.nameShort ?? 'VS Code';
409
const option = append(themeGrid, $('.sessions-walkthrough-vscode-theme-option'));
410
vscodeThemeBtn = append(option, $('div.sessions-walkthrough-vscode-theme-radio'));
411
vscodeThemeBtn.setAttribute('role', 'radio');
412
vscodeThemeBtn.setAttribute('aria-checked', 'false');
413
vscodeThemeBtn.setAttribute('tabindex', '0');
414
const labelText = localize(
415
'walkthrough.theme.useVSCodeTheme',
416
"Use My {0} Theme \u00b7 {1}",
417
parentName,
418
parentThemeSettingsId,
419
);
420
vscodeThemeBtn.textContent = labelText;
421
let previewDisposable: IDisposable | undefined;
422
const selectVSCodeTheme = async () => {
423
for (const c of themeCards) {
424
c.classList.remove('selected');
425
c.setAttribute('aria-checked', 'false');
426
}
427
vscodeThemeBtn!.classList.add('selected');
428
vscodeThemeBtn!.setAttribute('aria-checked', 'true');
429
isVSCodeThemeSelected = true;
430
431
// Preview the theme (temporary install from host location)
432
previewDisposable?.dispose();
433
previewDisposable = await this.themeImporterService.previewVSCodeTheme();
434
vscodeThemeBtn!.textContent = labelText;
435
};
436
// Dispose preview on step teardown (escape)
437
stepDisposables.add(toDisposable(() => previewDisposable?.dispose()));
438
stepDisposables.add(Gesture.addTarget(vscodeThemeBtn));
439
for (const eventType of [EventType.CLICK, TouchEventType.Tap]) {
440
stepDisposables.add(addDisposableListener(vscodeThemeBtn, eventType, selectVSCodeTheme));
441
}
442
stepDisposables.add(addDisposableListener(vscodeThemeBtn, EventType.KEY_DOWN, (e: KeyboardEvent) => {
443
if (e.key === 'Enter' || e.key === ' ') {
444
e.preventDefault();
445
vscodeThemeBtn!.click();
446
}
447
}));
448
}
449
450
// Footer with Continue button
451
const actions = append(this.footerContainer, $('.sessions-walkthrough-theme-footer'));
452
const continueBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement;
453
continueBtn.textContent = localize('walkthrough.theme.continue', "Continue");
454
stepDisposables.add(addDisposableListener(continueBtn, EventType.CLICK, async () => {
455
if (isVSCodeThemeSelected) {
456
await this.themeImporterService.importVSCodeTheme();
457
}
458
this._isShowingWelcome = false;
459
this._isShowingThemeStep = false;
460
this.complete();
461
}));
462
463
this.currentFocusableElements = [...themeCards, ...(vscodeThemeBtn ? [vscodeThemeBtn] : []), continueBtn];
464
465
stepDisposables.add(disposableTimeout(() => {
466
if (this.overlay.isConnected) {
467
continueBtn.focus();
468
}
469
}, 0));
470
}
471
472
private _createThemeCard(stepDisposables: DisposableStore, parent: HTMLElement, theme: IProductOnboardingTheme, allCards: HTMLElement[], selectedThemeId: string, onSelect: (id: string) => void): HTMLElement {
473
const card = append(parent, $('div.sessions-walkthrough-theme-card'));
474
card.setAttribute('role', 'radio');
475
card.setAttribute('aria-checked', theme.id === selectedThemeId ? 'true' : 'false');
476
card.setAttribute('aria-label', theme.label);
477
card.setAttribute('tabindex', '0');
478
479
if (theme.id === selectedThemeId) {
480
card.classList.add('selected');
481
}
482
483
// SVG preview image
484
const preview = append(card, $('div.sessions-walkthrough-theme-preview'));
485
const img = append(preview, $<HTMLImageElement>('img.sessions-walkthrough-theme-preview-img'));
486
img.alt = '';
487
img.src = FileAccess.asBrowserUri(`vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-${theme.id}.svg`).toString(true);
488
489
// Label
490
const label = append(card, $('div.sessions-walkthrough-theme-label'));
491
label.textContent = theme.label;
492
493
const selectCard = () => {
494
onSelect(theme.id);
495
this._applyTheme(theme);
496
for (const c of allCards) {
497
c.classList.remove('selected');
498
c.setAttribute('aria-checked', 'false');
499
}
500
card.classList.add('selected');
501
card.setAttribute('aria-checked', 'true');
502
};
503
stepDisposables.add(Gesture.addTarget(card));
504
for (const eventType of [EventType.CLICK, TouchEventType.Tap]) {
505
stepDisposables.add(addDisposableListener(card, eventType, selectCard));
506
}
507
508
stepDisposables.add(addDisposableListener(card, EventType.KEY_DOWN, (e: KeyboardEvent) => {
509
if (e.key === 'Enter' || e.key === ' ') {
510
e.preventDefault();
511
card.click();
512
}
513
}));
514
515
return card;
516
}
517
518
private async _applyTheme(theme: IProductOnboardingTheme): Promise<void> {
519
const allThemes = await this.themeService.getColorThemes();
520
const match = allThemes.find(t => t.settingsId === theme.themeId);
521
if (match) {
522
this.themeService.setColorTheme(match.id, ConfigurationTarget.USER);
523
}
524
}
525
526
private async _runSignIn(providerButtons: HTMLButtonElement[], error: HTMLElement, strategy: ChatSetupStrategy, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {
527
await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions);
528
if (this._shouldAbortUpdate(titleEl, subtitleEl)) {
529
return;
530
}
531
532
try {
533
const success = await this.commandService.executeCommand<boolean>(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, {
534
setupStrategy: strategy
535
});
536
537
if (this._shouldAbortUpdate(titleEl, subtitleEl)) {
538
return;
539
}
540
541
if (success) {
542
titleEl.textContent = localize('walkthrough.signingIn', "Finishing setup\u2026");
543
subtitleEl.textContent = localize('walkthrough.finishingSubtitle', "Getting everything ready for you.");
544
545
this.logService.info('[sessions walkthrough] Restarting extension host after setup');
546
const stopped = await this.extensionService.stopExtensionHosts(
547
localize('walkthrough.restart', "Completing Agents setup")
548
);
549
if (this._shouldAbortUpdate(titleEl, subtitleEl)) {
550
return;
551
}
552
if (stopped) {
553
await this.extensionService.startExtensionHosts();
554
if (this._shouldAbortUpdate(titleEl, subtitleEl)) {
555
return;
556
}
557
}
558
this._renderThemeStep();
559
} else {
560
await this._showErrorAndReset(error, localize('walkthrough.canceledError', "Sign-in was canceled. Please try again."));
561
}
562
} catch (err) {
563
this.logService.error('[sessions walkthrough] Sign-in failed:', err);
564
await this._showErrorAndReset(error, localize('walkthrough.signInError', "Something went wrong. Please try again."));
565
}
566
}
567
568
/**
569
* Web sign-in: uses IAuthenticationService to create a GitHub session
570
* with the scopes defined in product.json. On production vscode.dev
571
* this triggers an OAuth popup. On localhost the embedder's
572
* env-contributed auth provider handles the flow (e.g. device code).
573
*/
574
private async _runSignInWeb(providerButtons: HTMLButtonElement[], error: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {
575
await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions);
576
if (this._shouldAbortUpdate(titleEl, subtitleEl)) {
577
return;
578
}
579
580
try {
581
const scopes = this.productService.defaultChatAgent?.providerScopes?.[0]
582
?? ['read:user', 'user:email', 'repo', 'workflow'];
583
await this.authenticationService.createSession('github', scopes, { activateImmediate: true });
584
this._renderThemeStep();
585
} catch (err) {
586
this.logService.error('[sessions walkthrough] Web sign-in failed:', err);
587
await this._showErrorAndReset(error, localize('walkthrough.signInError', "Something went wrong. Please try again."));
588
}
589
}
590
591
private async _fadeToProgress(providerButtons: HTMLButtonElement[], error: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {
592
// Disable all provider buttons
593
for (const btn of providerButtons) {
594
btn.disabled = true;
595
}
596
this.currentFocusableElements = [];
597
598
error.style.display = 'none';
599
600
// Fade the content
601
this.disclaimerElement.classList.add('hidden');
602
this.contentContainer.classList.add('sessions-walkthrough-fade-out');
603
await this._wait(fadeDuration);
604
if (this._shouldAbortUpdate(titleEl, subtitleEl, signInActions)) {
605
return;
606
}
607
608
// Swap title and subtitle in-place
609
titleEl.textContent = localize('walkthrough.settingUp', "Signing in\u2026");
610
subtitleEl.textContent = localize('walkthrough.poweredBy', "Complete authorization in your browser.");
611
612
// Replace sign-in actions with progress bar
613
const heroText = signInActions.parentElement;
614
if (!heroText) {
615
return;
616
}
617
signInActions.remove();
618
append(heroText, $('.sessions-walkthrough-progress-bar', undefined, $('.sessions-walkthrough-progress-bar-fill')));
619
620
// Fade back in
621
this.contentContainer.classList.remove('sessions-walkthrough-fade-out');
622
}
623
624
private async _showErrorAndReset(error: HTMLElement, message: string): Promise<void> {
625
error.textContent = message;
626
error.style.display = '';
627
await this._wait(resetMessageDuration);
628
if (this._shouldAbortUpdate(error)) {
629
return;
630
}
631
error.style.display = 'none';
632
633
this.contentContainer.classList.add('sessions-walkthrough-fade-out');
634
await this._wait(fadeDuration);
635
if (!this.overlay.isConnected) {
636
return;
637
}
638
this.contentContainer.classList.remove('sessions-walkthrough-fade-out');
639
this._renderSignIn();
640
}
641
642
// ------------------------------------------------------------------
643
// Lifecycle
644
645
complete(): void {
646
this._finish('completed');
647
}
648
649
private _finish(outcome: WalkthroughOutcome): void {
650
this.overlay.classList.add('sessions-walkthrough-dismissed');
651
this._register(disposableTimeout(() => this.dispose(), dismissDuration));
652
if (!this._outcomeResolved) {
653
this._outcomeResolved = true;
654
this._resolveOutcome(outcome);
655
}
656
}
657
658
dismiss(): void {
659
this._finish('dismissed');
660
}
661
662
override dispose(): void {
663
// If the overlay is disposed without an explicit finish (e.g. cleared by
664
// the owner's DisposableStore), treat it as a dismissal so that `outcome`
665
// always resolves and callers are never left waiting on a pending promise.
666
if (!this._outcomeResolved) {
667
this._outcomeResolved = true;
668
this._resolveOutcome('dismissed');
669
}
670
super.dispose();
671
if (this.previouslyFocusedElement?.isConnected) {
672
this.previouslyFocusedElement.focus();
673
}
674
}
675
676
private _trapFocus(event: KeyboardEvent): void {
677
const focusableElements = this._getFocusableElements();
678
if (!focusableElements.length) {
679
return;
680
}
681
682
const activeElement = getActiveElement();
683
const fallbackElement = event.shiftKey ? focusableElements[focusableElements.length - 1] : focusableElements[0];
684
if (!isHTMLElement(activeElement)) {
685
event.preventDefault();
686
fallbackElement?.focus();
687
return;
688
}
689
690
const focusedIndex = focusableElements.indexOf(activeElement);
691
if (focusedIndex === -1) {
692
event.preventDefault();
693
fallbackElement?.focus();
694
return;
695
}
696
697
if (!event.shiftKey && focusedIndex === focusableElements.length - 1) {
698
event.preventDefault();
699
focusableElements[0].focus();
700
} else if (event.shiftKey && focusedIndex === 0) {
701
event.preventDefault();
702
focusableElements[focusableElements.length - 1]?.focus();
703
}
704
}
705
706
private _getFocusableElements(): HTMLElement[] {
707
return this.currentFocusableElements.filter(element => element.isConnected);
708
}
709
710
private _wait(duration: number): Promise<void> {
711
return new Promise(resolve => {
712
let didResolve = false;
713
const timeoutDisposables = this.stepDisposables.value?.add(new DisposableStore()) ?? this._register(new DisposableStore());
714
const complete = () => {
715
if (didResolve) {
716
return;
717
}
718
719
didResolve = true;
720
timeoutDisposables.dispose();
721
resolve();
722
};
723
724
timeoutDisposables.add(disposableTimeout(complete, duration));
725
timeoutDisposables.add(toDisposable(complete));
726
});
727
}
728
729
private _shouldAbortUpdate(...elements: HTMLElement[]): boolean {
730
return !this.overlay.isConnected || elements.some(element => !element.isConnected);
731
}
732
733
private _createDisclaimer(): { element: HTMLElement; links: readonly HTMLAnchorElement[] } {
734
const defaultChatAgent = this.productService.defaultChatAgent;
735
const disclaimer = append(this.overlay, $('p.sessions-walkthrough-disclaimer.hidden'));
736
const termsStatementUrl = defaultChatAgent?.termsStatementUrl || fallbackChatAgentLinks.termsStatementUrl;
737
const privacyStatementUrl = defaultChatAgent?.privacyStatementUrl || fallbackChatAgentLinks.privacyStatementUrl;
738
const publicCodeMatchesUrl = defaultChatAgent?.publicCodeMatchesUrl || fallbackChatAgentLinks.publicCodeMatchesUrl;
739
const manageSettingsUrl = defaultChatAgent?.manageSettingsUrl || fallbackChatAgentLinks.manageSettingsUrl;
740
741
const termsLink = this._appendDisclaimerLink(termsStatementUrl, localize('walkthrough.disclaimer.terms', "Terms"));
742
const privacyLink = this._appendDisclaimerLink(privacyStatementUrl, localize('walkthrough.disclaimer.privacy', "Privacy Statement"));
743
const publicCodeLink = this._appendDisclaimerLink(publicCodeMatchesUrl, localize('walkthrough.disclaimer.publicCode', "public code"));
744
const settingsLink = this._appendDisclaimerLink(manageSettingsUrl, localize('walkthrough.disclaimer.settings', "settings"));
745
746
append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.prefix', "By continuing, you agree to GitHub's ")));
747
disclaimer.appendChild(termsLink);
748
append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.middle', " and ")));
749
disclaimer.appendChild(privacyLink);
750
append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.suffix', ". GitHub Copilot may show ")));
751
disclaimer.appendChild(publicCodeLink);
752
append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.final', " suggestions and use your data to improve the product. You can change these ")));
753
disclaimer.appendChild(settingsLink);
754
append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.end', " anytime.")));
755
756
return {
757
element: disclaimer,
758
links: [termsLink, privacyLink, publicCodeLink, settingsLink]
759
};
760
}
761
762
private _appendDisclaimerLink(href: string, label: string): HTMLAnchorElement {
763
const link = $('a', { href }, label) as HTMLAnchorElement;
764
this._register(addDisposableListener(link, EventType.CLICK, e => {
765
e.preventDefault();
766
e.stopPropagation();
767
if (href) {
768
void this.openerService.open(URI.parse(href), { fromUserGesture: true });
769
}
770
}));
771
return link;
772
}
773
}
774
775