Path: blob/main/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/sessionsWalkthrough.css';6import { disposableTimeout } from '../../../../base/common/async.js';7import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';8import { $, addDisposableGenericMouseDownListener, append, EventType, addDisposableListener, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js';9import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';10import { localize } from '../../../../nls.js';11import { FileAccess } from '../../../../base/common/network.js';12import { IProductOnboardingTheme } from '../../../../base/common/product.js';13import { ICommandService } from '../../../../platform/commands/common/commands.js';14import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';15import { ILogService } from '../../../../platform/log/common/log.js';16import { IOpenerService } from '../../../../platform/opener/common/opener.js';17import { IProductService } from '../../../../platform/product/common/productService.js';18import { isWeb } from '../../../../base/common/platform.js';19import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';20import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';21import { URI } from '../../../../base/common/uri.js';22import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';23import { ChatSetupStrategy } from '../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.js';24import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js';25import { IWorkbenchThemeService } from '../../../../workbench/services/themes/common/workbenchThemeService.js';26import { IThemeImporterService } from '../../../services/vscode/common/themeImporter.js';2728export type WalkthroughOutcome = 'completed' | 'dismissed';2930const fadeDuration = 200;31const resetMessageDuration = 2000;32const dismissDuration = 250;33const fallbackChatAgentLinks = {34termsStatementUrl: 'https://aka.ms/github-copilot-terms-statement',35privacyStatementUrl: 'https://aka.ms/github-copilot-privacy-statement',36publicCodeMatchesUrl: 'https://aka.ms/github-copilot-match-public-code',37manageSettingsUrl: 'https://aka.ms/github-copilot-settings'38};3940/**41* Sign-in onboarding overlay:42* - Sign in via GitHub / Google / Apple43*/44export class SessionsWalkthroughOverlay extends Disposable {4546private readonly overlay: HTMLElement;47private readonly card: HTMLElement;48private readonly contentContainer: HTMLElement;49private readonly footerContainer: HTMLElement;50private readonly disclaimerElement: HTMLElement;51private readonly disclaimerLinks: readonly HTMLAnchorElement[];52private readonly stepDisposables = this._register(new MutableDisposable<DisposableStore>());53private readonly previouslyFocusedElement: HTMLElement | undefined;54private currentFocusableElements: readonly HTMLElement[] = [];55private _resolveOutcome!: (outcome: WalkthroughOutcome) => void;56private _outcomeResolved = false;57private _isShowingWelcome = false;58private _isShowingSignIn = false;59private _isShowingThemeStep = false;6061/**62* Whether the overlay is currently displaying the signed-in welcome63* greeting (as opposed to the sign-in provider buttons). When `true`,64* external callers should **not** auto-dismiss the overlay — the user65* is expected to click "Get Started" to proceed.66*/67get isShowingWelcome(): boolean { return this._isShowingWelcome; }6869/**70* Whether the overlay is currently displaying the sign-in buttons.71* Only `true` after the sign-in screen has been fully rendered —72* deliberately `false` during the loading phase so that external73* account resolution (e.g. VS Code signing in) cannot auto-dismiss74* the overlay before the user has had a chance to interact.75*/76get isShowingSignIn(): boolean { return this._isShowingSignIn; }7778/**79* Transition to the theme selection step. Called by external code80* (e.g. the contribution) when the user signs in while the sign-in81* screen is visible, so the user still gets to pick a theme before82* the overlay dismisses.83*/84showThemeStep(): void {85this._isShowingSignIn = false;86this._renderThemeStep();87}8889/** Resolves when the user completes or dismisses the walkthrough. */90readonly outcome: Promise<WalkthroughOutcome> = new Promise(resolve => { this._resolveOutcome = resolve; });9192constructor(93container: HTMLElement,94private readonly _isFirstLaunch: boolean,95@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,96@IAuthenticationService private readonly authenticationService: IAuthenticationService,97@ICommandService private readonly commandService: ICommandService,98@IExtensionService private readonly extensionService: IExtensionService,99@IOpenerService private readonly openerService: IOpenerService,100@IProductService private readonly productService: IProductService,101@IThemeImporterService private readonly themeImporterService: IThemeImporterService,102@IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService,103@ILogService private readonly logService: ILogService,104) {105super();106107const activeElement = getActiveElement();108this.previouslyFocusedElement = isHTMLElement(activeElement) ? activeElement : undefined;109110this.overlay = append(container, $('.sessions-walkthrough-overlay'));111this.overlay.setAttribute('role', 'dialog');112this.overlay.setAttribute('aria-modal', 'true');113this.overlay.setAttribute('aria-label', localize('walkthrough.aria', "Agents onboarding walkthrough"));114this._register(toDisposable(() => this.overlay.remove()));115this._register(addDisposableListener(this.overlay, EventType.KEY_DOWN, (e: KeyboardEvent) => {116if (e.key === 'Escape') {117if (this._isShowingThemeStep) {118// Remove the theme setting to reset to default119this.themeService.setColorTheme(undefined, ConfigurationTarget.USER);120this._isShowingWelcome = false;121this._isShowingThemeStep = false;122this.complete();123}124e.preventDefault();125e.stopPropagation();126return;127}128129if (e.key === 'Tab') {130this._trapFocus(e);131}132}));133this._register(addDisposableGenericMouseDownListener(this.overlay, e => {134if (e.target === this.overlay) {135e.preventDefault();136e.stopPropagation();137}138}));139140this.card = append(this.overlay, $('.sessions-walkthrough-card'));141142// Scrollable content area143this.contentContainer = append(this.card, $('.sessions-walkthrough-content'));144145// Fixed footer146this.footerContainer = append(this.card, $('.sessions-walkthrough-footer'));147const disclaimer = this._createDisclaimer();148this.disclaimerElement = disclaimer.element;149this.disclaimerLinks = disclaimer.links;150151// Set synchronously so the autorun in the contribution doesn't152// auto-dismiss before the async _renderSignIn completes.153// On first launch, optimistically assume signed in — the welcome154// screen renders the same regardless, and we update before painting.155if (this._isFirstLaunch) {156this._isShowingWelcome = true;157}158159if (this._isFirstLaunch) {160// First launch: render a loading state while the default account resolves.161// Reading `currentDefaultAccount` synchronously here would always return null162// and cause us to render the sign-in screen for users who are actually signed in.163this._renderLoading();164this.defaultAccountService.getDefaultAccount().then(() => {165if (this._outcomeResolved) {166return;167}168this._isShowingWelcome = this._isSignedIn();169this._renderSignIn();170});171} else {172// Sign-out scenario (returning user who is now signed out): account is173// already known to be null, so render the sign-in screen immediately.174this._isShowingWelcome = false;175this._renderSignIn();176}177}178179/**180* Renders a centered animated agents icon as the loading state.181* Used while the default account is being resolved on startup, before182* the welcome content is rendered.183*/184private _renderLoading(): void {185this.contentContainer.textContent = '';186this.footerContainer.textContent = '';187this.disclaimerElement.classList.add('hidden');188189const loadingIndicator = append(this.contentContainer, $('div.sessions-walkthrough-loading-indicator')) as HTMLElement;190loadingIndicator.setAttribute('role', 'status');191loadingIndicator.setAttribute('aria-busy', 'true');192loadingIndicator.setAttribute('aria-label', localize('walkthrough.loading', "Loading"));193append(loadingIndicator, $('div.sessions-walkthrough-logo.sessions-walkthrough-loading-icon'));194}195196// ------------------------------------------------------------------197// Sign In198199private _renderSignIn(): void {200const stepDisposables = this.stepDisposables.value = new DisposableStore();201202this.contentContainer.textContent = '';203this.footerContainer.textContent = '';204this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0);205206const productName = this.productService.nameLong;207208// Horizontal layout: icon left, text + buttons right209const layout = append(this.contentContainer, $('.sessions-walkthrough-hero'));210211append(layout, $('div.sessions-walkthrough-logo'));212213const right = append(layout, $('.sessions-walkthrough-hero-text'));214215// First time + signed in → welcome greeting with "Get Started"216if (this._isFirstLaunch && this._isSignedIn()) {217this._renderWelcome(stepDisposables, right, productName);218return;219}220221// Always show the welcome title/subtitle with sign-in buttons,222// whether it's the first launch or a returning user who is signed out.223const titleEl = append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName)));224const subtitleEl = append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you.")));225append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!")));226227this._renderSignInButtons(stepDisposables, right, titleEl, subtitleEl);228}229230private _renderSignInButtons(stepDisposables: DisposableStore, right: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement): void {231this._isShowingSignIn = true;232const signInActions = append(right, $('.sessions-walkthrough-sign-in-actions'));233const providerRow = append(signInActions, $('.sessions-walkthrough-providers-row'));234235const githubBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-primary.provider-github')) as HTMLButtonElement;236append(githubBtn, $('span.sessions-walkthrough-provider-label', undefined, localize('walkthrough.signin.github', "Sign in with GitHub")));237238// Desktop-only provider buttons239let providerButtons: HTMLButtonElement[];240if (isWeb) {241providerButtons = [githubBtn];242} else {243const googleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-google')) as HTMLButtonElement;244googleBtn.setAttribute('aria-label', localize('walkthrough.signin.google', "Continue with Google"));245googleBtn.title = localize('walkthrough.signin.google', "Continue with Google");246247const appleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-apple')) as HTMLButtonElement;248appleBtn.setAttribute('aria-label', localize('walkthrough.signin.apple', "Continue with Apple"));249appleBtn.title = localize('walkthrough.signin.apple', "Continue with Apple");250251const enterpriseProviderName = this.productService.defaultChatAgent?.provider?.enterprise?.name || 'GHE';252const enterpriseBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-compact.provider-enterprise')) as HTMLButtonElement;253enterpriseBtn.setAttribute('aria-label', localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName));254enterpriseBtn.title = localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName);255append(enterpriseBtn, $('span.sessions-walkthrough-provider-label', undefined, enterpriseProviderName));256257providerButtons = [githubBtn, googleBtn, appleBtn, enterpriseBtn];258}259260// Error feedback below providers261const errorContainer = append(this.footerContainer, $('p.sessions-walkthrough-error'));262errorContainer.style.display = 'none';263264// Focus the first provider button so keyboard users can interact immediately265disposableTimeout(() => {266if (this.overlay.isConnected && !githubBtn.disabled) {267githubBtn.focus();268}269}, 0, stepDisposables);270271this.currentFocusableElements = [...providerButtons, ...this.disclaimerLinks];272273if (isWeb) {274// Web: GitHub button uses IAuthenticationService with product scopes275stepDisposables.add(addDisposableListener(githubBtn, EventType.CLICK, () => this._runSignInWeb(276providerButtons,277errorContainer,278titleEl,279subtitleEl,280signInActions281)));282} else {283// Desktop: each button uses a different ChatSetupStrategy284const providerStrategies = [285ChatSetupStrategy.SetupWithoutEnterpriseProvider,286ChatSetupStrategy.SetupWithGoogleProvider,287ChatSetupStrategy.SetupWithAppleProvider,288ChatSetupStrategy.SetupWithEnterpriseProvider,289];290for (let i = 0; i < providerButtons.length; i++) {291const strategy = providerStrategies[i];292stepDisposables.add(addDisposableListener(providerButtons[i], EventType.CLICK, () => this._runSignIn(293providerButtons,294errorContainer,295strategy,296titleEl,297subtitleEl,298signInActions299)));300}301}302}303304// ------------------------------------------------------------------305// Welcome (first launch + signed in)306307private _renderWelcome(stepDisposables: DisposableStore, right: HTMLElement, productName: string): void {308this._isShowingWelcome = true;309this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0);310311append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName)));312append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered application where agents explore, build, and iterate with you.")));313append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!")));314315const actions = append(right, $('.sessions-walkthrough-welcome-actions'));316const getStartedBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement;317getStartedBtn.textContent = localize('walkthrough.welcome.getStarted', "Get Started");318stepDisposables.add(addDisposableListener(getStartedBtn, EventType.CLICK, () => {319this._isShowingWelcome = false;320this._renderThemeStep();321}));322323this.currentFocusableElements = [getStartedBtn, ...this.disclaimerLinks];324325disposableTimeout(() => {326if (this.overlay.isConnected) {327getStartedBtn.focus();328}329}, 0, stepDisposables);330}331332private _isSignedIn(): boolean {333return this.defaultAccountService.currentDefaultAccount !== null;334}335336// ------------------------------------------------------------------337// Theme Step338339private _renderThemeStep(): void {340const stepDisposables = this.stepDisposables.value = new DisposableStore();341this._isShowingWelcome = true;342this._isShowingThemeStep = true;343344// Start resolving the parent VS Code theme during the fade-out345const parentThemePromise = !isWeb346? this.themeImporterService.getVSCodeTheme()347: Promise.resolve(undefined);348349// Fade out current content, then render theme step350this.contentContainer.classList.add('sessions-walkthrough-fade-out');351stepDisposables.add(disposableTimeout(async () => {352if (!this.overlay.isConnected) {353return;354}355const parentTheme = await parentThemePromise;356if (!this.overlay.isConnected) {357return;358}359// Only show the VS Code theme option if the parent theme is different from the 4 onboarding themes360const allOnboardingThemes = this.productService.onboardingThemes ?? [];361const shownThemes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized'));362const parentThemeSettingsId = shownThemes.some(t => t.themeId === parentTheme) ? undefined : parentTheme;363this.contentContainer.classList.remove('sessions-walkthrough-fade-out');364this._renderThemeStepContent(stepDisposables, parentThemeSettingsId);365}, fadeDuration));366}367368private _renderThemeStepContent(stepDisposables: DisposableStore, parentThemeSettingsId: string | undefined): void {369this.contentContainer.textContent = '';370this.footerContainer.textContent = '';371this.disclaimerElement.classList.add('hidden');372373// Header374const header = append(this.contentContainer, $('.sessions-walkthrough-theme-header'));375append(header, $('h2', undefined, localize('walkthrough.theme.title', "Choose Your Theme")));376append(header, $('p', undefined, localize('walkthrough.theme.subtitle', "Pick a color theme to make it yours. You can always change it later.")));377378// Build theme list — exclude solarized variants for the base set379const allOnboardingThemes = this.productService.onboardingThemes ?? [];380const themes = allOnboardingThemes.filter(t => !t.id.startsWith('solarized'));381382const themeGrid = append(this.contentContainer, $('.sessions-walkthrough-theme-grid'));383themeGrid.setAttribute('role', 'radiogroup');384themeGrid.setAttribute('aria-label', localize('walkthrough.theme.ariaLabel', "Choose a color theme"));385386// Pre-select the onboarding theme matching the current theme, or fall back to first387const currentTheme = this.themeService.getColorTheme();388let selectedThemeId = themes.find(t => t.themeId === currentTheme.settingsId)?.id ?? themes[0]?.id;389390const themeCards: HTMLElement[] = [];391let vscodeThemeBtn: HTMLElement | undefined;392let isVSCodeThemeSelected = false;393for (const theme of themes) {394const card = this._createThemeCard(stepDisposables, themeGrid, theme, themeCards, selectedThemeId, id => {395selectedThemeId = id;396isVSCodeThemeSelected = false;397if (vscodeThemeBtn) {398vscodeThemeBtn.classList.remove('selected');399vscodeThemeBtn.setAttribute('aria-checked', 'false');400}401});402themeCards.push(card);403}404405// Show a VS Code theme option as a radio-style button inside the radiogroup406if (parentThemeSettingsId) {407const parentName = this.productService.embedded?.nameShort ?? 'VS Code';408const option = append(themeGrid, $('.sessions-walkthrough-vscode-theme-option'));409vscodeThemeBtn = append(option, $('div.sessions-walkthrough-vscode-theme-radio'));410vscodeThemeBtn.setAttribute('role', 'radio');411vscodeThemeBtn.setAttribute('aria-checked', 'false');412vscodeThemeBtn.setAttribute('tabindex', '0');413const labelText = localize(414'walkthrough.theme.useVSCodeTheme',415"Use My {0} Theme \u00b7 {1}",416parentName,417parentThemeSettingsId,418);419vscodeThemeBtn.textContent = labelText;420let previewDisposable: IDisposable | undefined;421const selectVSCodeTheme = async () => {422for (const c of themeCards) {423c.classList.remove('selected');424c.setAttribute('aria-checked', 'false');425}426vscodeThemeBtn!.classList.add('selected');427vscodeThemeBtn!.setAttribute('aria-checked', 'true');428isVSCodeThemeSelected = true;429430// Preview the theme (temporary install from host location)431previewDisposable?.dispose();432previewDisposable = await this.themeImporterService.previewVSCodeTheme();433vscodeThemeBtn!.textContent = labelText;434};435// Dispose preview on step teardown (escape)436stepDisposables.add(toDisposable(() => previewDisposable?.dispose()));437stepDisposables.add(Gesture.addTarget(vscodeThemeBtn));438for (const eventType of [EventType.CLICK, TouchEventType.Tap]) {439stepDisposables.add(addDisposableListener(vscodeThemeBtn, eventType, selectVSCodeTheme));440}441stepDisposables.add(addDisposableListener(vscodeThemeBtn, EventType.KEY_DOWN, (e: KeyboardEvent) => {442if (e.key === 'Enter' || e.key === ' ') {443e.preventDefault();444vscodeThemeBtn!.click();445}446}));447}448449// Footer with Continue button450const actions = append(this.footerContainer, $('.sessions-walkthrough-theme-footer'));451const continueBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement;452continueBtn.textContent = localize('walkthrough.theme.continue', "Continue");453stepDisposables.add(addDisposableListener(continueBtn, EventType.CLICK, async () => {454if (isVSCodeThemeSelected) {455await this.themeImporterService.importVSCodeTheme();456}457this._isShowingWelcome = false;458this._isShowingThemeStep = false;459this.complete();460}));461462this.currentFocusableElements = [...themeCards, ...(vscodeThemeBtn ? [vscodeThemeBtn] : []), continueBtn];463464stepDisposables.add(disposableTimeout(() => {465if (this.overlay.isConnected) {466continueBtn.focus();467}468}, 0));469}470471private _createThemeCard(stepDisposables: DisposableStore, parent: HTMLElement, theme: IProductOnboardingTheme, allCards: HTMLElement[], selectedThemeId: string, onSelect: (id: string) => void): HTMLElement {472const card = append(parent, $('div.sessions-walkthrough-theme-card'));473card.setAttribute('role', 'radio');474card.setAttribute('aria-checked', theme.id === selectedThemeId ? 'true' : 'false');475card.setAttribute('aria-label', theme.label);476card.setAttribute('tabindex', '0');477478if (theme.id === selectedThemeId) {479card.classList.add('selected');480}481482// SVG preview image483const preview = append(card, $('div.sessions-walkthrough-theme-preview'));484const img = append(preview, $<HTMLImageElement>('img.sessions-walkthrough-theme-preview-img'));485img.alt = '';486img.src = FileAccess.asBrowserUri(`vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-${theme.id}.svg`).toString(true);487488// Label489const label = append(card, $('div.sessions-walkthrough-theme-label'));490label.textContent = theme.label;491492const selectCard = () => {493onSelect(theme.id);494this._applyTheme(theme);495for (const c of allCards) {496c.classList.remove('selected');497c.setAttribute('aria-checked', 'false');498}499card.classList.add('selected');500card.setAttribute('aria-checked', 'true');501};502stepDisposables.add(Gesture.addTarget(card));503for (const eventType of [EventType.CLICK, TouchEventType.Tap]) {504stepDisposables.add(addDisposableListener(card, eventType, selectCard));505}506507stepDisposables.add(addDisposableListener(card, EventType.KEY_DOWN, (e: KeyboardEvent) => {508if (e.key === 'Enter' || e.key === ' ') {509e.preventDefault();510card.click();511}512}));513514return card;515}516517private async _applyTheme(theme: IProductOnboardingTheme): Promise<void> {518const allThemes = await this.themeService.getColorThemes();519const match = allThemes.find(t => t.settingsId === theme.themeId);520if (match) {521this.themeService.setColorTheme(match.id, ConfigurationTarget.USER);522}523}524525private async _runSignIn(providerButtons: HTMLButtonElement[], error: HTMLElement, strategy: ChatSetupStrategy, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {526await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions);527if (this._shouldAbortUpdate(titleEl, subtitleEl)) {528return;529}530531try {532const success = await this.commandService.executeCommand<boolean>(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, {533setupStrategy: strategy534});535536if (this._shouldAbortUpdate(titleEl, subtitleEl)) {537return;538}539540if (success) {541titleEl.textContent = localize('walkthrough.signingIn', "Finishing setup\u2026");542subtitleEl.textContent = localize('walkthrough.finishingSubtitle', "Getting everything ready for you.");543544this.logService.info('[sessions walkthrough] Restarting extension host after setup');545const stopped = await this.extensionService.stopExtensionHosts(546localize('walkthrough.restart', "Completing Agents setup")547);548if (this._shouldAbortUpdate(titleEl, subtitleEl)) {549return;550}551if (stopped) {552await this.extensionService.startExtensionHosts();553if (this._shouldAbortUpdate(titleEl, subtitleEl)) {554return;555}556}557this._renderThemeStep();558} else {559await this._showErrorAndReset(error, localize('walkthrough.canceledError', "Sign-in was canceled. Please try again."));560}561} catch (err) {562this.logService.error('[sessions walkthrough] Sign-in failed:', err);563await this._showErrorAndReset(error, localize('walkthrough.signInError', "Something went wrong. Please try again."));564}565}566567/**568* Web sign-in: uses IAuthenticationService to create a GitHub session569* with the scopes defined in product.json. On production vscode.dev570* this triggers an OAuth popup. On localhost the embedder's571* env-contributed auth provider handles the flow (e.g. device code).572*/573private async _runSignInWeb(providerButtons: HTMLButtonElement[], error: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {574await this._fadeToProgress(providerButtons, error, titleEl, subtitleEl, signInActions);575if (this._shouldAbortUpdate(titleEl, subtitleEl)) {576return;577}578579try {580const scopes = this.productService.defaultChatAgent?.providerScopes?.[0]581?? ['read:user', 'user:email', 'repo', 'workflow'];582await this.authenticationService.createSession('github', scopes, { activateImmediate: true });583this._renderThemeStep();584} catch (err) {585this.logService.error('[sessions walkthrough] Web sign-in failed:', err);586await this._showErrorAndReset(error, localize('walkthrough.signInError', "Something went wrong. Please try again."));587}588}589590private async _fadeToProgress(providerButtons: HTMLButtonElement[], error: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise<void> {591// Disable all provider buttons592for (const btn of providerButtons) {593btn.disabled = true;594}595this.currentFocusableElements = [];596597error.style.display = 'none';598599// Fade the content600this.disclaimerElement.classList.add('hidden');601this.contentContainer.classList.add('sessions-walkthrough-fade-out');602await this._wait(fadeDuration);603if (this._shouldAbortUpdate(titleEl, subtitleEl, signInActions)) {604return;605}606607// Swap title and subtitle in-place608titleEl.textContent = localize('walkthrough.settingUp', "Signing in\u2026");609subtitleEl.textContent = localize('walkthrough.poweredBy', "Complete authorization in your browser.");610611// Replace sign-in actions with progress bar612const heroText = signInActions.parentElement;613if (!heroText) {614return;615}616signInActions.remove();617append(heroText, $('.sessions-walkthrough-progress-bar', undefined, $('.sessions-walkthrough-progress-bar-fill')));618619// Fade back in620this.contentContainer.classList.remove('sessions-walkthrough-fade-out');621}622623private async _showErrorAndReset(error: HTMLElement, message: string): Promise<void> {624error.textContent = message;625error.style.display = '';626await this._wait(resetMessageDuration);627if (this._shouldAbortUpdate(error)) {628return;629}630error.style.display = 'none';631632this.contentContainer.classList.add('sessions-walkthrough-fade-out');633await this._wait(fadeDuration);634if (!this.overlay.isConnected) {635return;636}637this.contentContainer.classList.remove('sessions-walkthrough-fade-out');638this._renderSignIn();639}640641// ------------------------------------------------------------------642// Lifecycle643644complete(): void {645this._finish('completed');646}647648private _finish(outcome: WalkthroughOutcome): void {649this.overlay.classList.add('sessions-walkthrough-dismissed');650this._register(disposableTimeout(() => this.dispose(), dismissDuration));651if (!this._outcomeResolved) {652this._outcomeResolved = true;653this._resolveOutcome(outcome);654}655}656657dismiss(): void {658this._finish('dismissed');659}660661override dispose(): void {662// If the overlay is disposed without an explicit finish (e.g. cleared by663// the owner's DisposableStore), treat it as a dismissal so that `outcome`664// always resolves and callers are never left waiting on a pending promise.665if (!this._outcomeResolved) {666this._outcomeResolved = true;667this._resolveOutcome('dismissed');668}669super.dispose();670if (this.previouslyFocusedElement?.isConnected) {671this.previouslyFocusedElement.focus();672}673}674675private _trapFocus(event: KeyboardEvent): void {676const focusableElements = this._getFocusableElements();677if (!focusableElements.length) {678return;679}680681const activeElement = getActiveElement();682const fallbackElement = event.shiftKey ? focusableElements[focusableElements.length - 1] : focusableElements[0];683if (!isHTMLElement(activeElement)) {684event.preventDefault();685fallbackElement?.focus();686return;687}688689const focusedIndex = focusableElements.indexOf(activeElement);690if (focusedIndex === -1) {691event.preventDefault();692fallbackElement?.focus();693return;694}695696if (!event.shiftKey && focusedIndex === focusableElements.length - 1) {697event.preventDefault();698focusableElements[0].focus();699} else if (event.shiftKey && focusedIndex === 0) {700event.preventDefault();701focusableElements[focusableElements.length - 1]?.focus();702}703}704705private _getFocusableElements(): HTMLElement[] {706return this.currentFocusableElements.filter(element => element.isConnected);707}708709private _wait(duration: number): Promise<void> {710return new Promise(resolve => {711let didResolve = false;712const timeoutDisposables = this.stepDisposables.value?.add(new DisposableStore()) ?? this._register(new DisposableStore());713const complete = () => {714if (didResolve) {715return;716}717718didResolve = true;719timeoutDisposables.dispose();720resolve();721};722723timeoutDisposables.add(disposableTimeout(complete, duration));724timeoutDisposables.add(toDisposable(complete));725});726}727728private _shouldAbortUpdate(...elements: HTMLElement[]): boolean {729return !this.overlay.isConnected || elements.some(element => !element.isConnected);730}731732private _createDisclaimer(): { element: HTMLElement; links: readonly HTMLAnchorElement[] } {733const defaultChatAgent = this.productService.defaultChatAgent;734const disclaimer = append(this.overlay, $('p.sessions-walkthrough-disclaimer.hidden'));735const termsStatementUrl = defaultChatAgent?.termsStatementUrl || fallbackChatAgentLinks.termsStatementUrl;736const privacyStatementUrl = defaultChatAgent?.privacyStatementUrl || fallbackChatAgentLinks.privacyStatementUrl;737const publicCodeMatchesUrl = defaultChatAgent?.publicCodeMatchesUrl || fallbackChatAgentLinks.publicCodeMatchesUrl;738const manageSettingsUrl = defaultChatAgent?.manageSettingsUrl || fallbackChatAgentLinks.manageSettingsUrl;739740const termsLink = this._appendDisclaimerLink(termsStatementUrl, localize('walkthrough.disclaimer.terms', "Terms"));741const privacyLink = this._appendDisclaimerLink(privacyStatementUrl, localize('walkthrough.disclaimer.privacy', "Privacy Statement"));742const publicCodeLink = this._appendDisclaimerLink(publicCodeMatchesUrl, localize('walkthrough.disclaimer.publicCode', "public code"));743const settingsLink = this._appendDisclaimerLink(manageSettingsUrl, localize('walkthrough.disclaimer.settings', "settings"));744745append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.prefix', "By continuing, you agree to GitHub's ")));746disclaimer.appendChild(termsLink);747append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.middle', " and ")));748disclaimer.appendChild(privacyLink);749append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.suffix', ". GitHub Copilot may show ")));750disclaimer.appendChild(publicCodeLink);751append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.final', " suggestions and use your data to improve the product. You can change these ")));752disclaimer.appendChild(settingsLink);753append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.end', " anytime.")));754755return {756element: disclaimer,757links: [termsLink, privacyLink, publicCodeLink, settingsLink]758};759}760761private _appendDisclaimerLink(href: string, label: string): HTMLAnchorElement {762const link = $('a', { href }, label) as HTMLAnchorElement;763this._register(addDisposableListener(link, EventType.CLICK, e => {764e.preventDefault();765e.stopPropagation();766if (href) {767void this.openerService.open(URI.parse(href), { fromUserGesture: true });768}769}));770return link;771}772}773774775