Skip to content

Commit d742ad2

Browse files
axosoft-ramintd13
authored andcommitted
Adds new banner component and promo implementation (#4473)
1 parent 8eccd59 commit d742ad2

19 files changed

+804
-4
lines changed

docs/telemetry-events.md

Lines changed: 25 additions & 1 deletion
Large diffs are not rendered by default.

src/commands/resets.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ const resetTypes = [
1212
'ai',
1313
'ai:confirmations',
1414
'avatars',
15+
'homeSections',
1516
'integrations',
1617
'previews',
18+
'promoOptIns',
1719
'repositoryAccess',
1820
'subscription',
1921
'suppressedWarnings',
@@ -46,6 +48,11 @@ export class ResetCommand extends GlCommandBase {
4648
detail: 'Clears the stored avatar cache',
4749
item: 'avatars',
4850
},
51+
{
52+
label: 'Home Sections...',
53+
detail: 'Clears dismissed home view banners and sections',
54+
item: 'homeSections',
55+
},
4956
{
5057
label: 'Integrations (Authentication)...',
5158
detail: 'Clears any locally stored authentication for integrations',
@@ -93,6 +100,11 @@ export class ResetCommand extends GlCommandBase {
93100
detail: 'Resets the stored state for feature previews',
94101
item: 'previews',
95102
},
103+
{
104+
label: 'Promo Opt-Ins...',
105+
detail: 'Clears any locally stored promo opt-ins',
106+
item: 'promoOptIns'
107+
},
96108
);
97109
}
98110

@@ -133,6 +145,10 @@ export class ResetCommand extends GlCommandBase {
133145
confirmationMessage = 'Are you sure you want to reset the stored state for feature previews?';
134146
confirm.title = 'Reset Feature Previews';
135147
break;
148+
case 'promoOptIns':
149+
confirmationMessage = 'Are you sure you want to reset all of the locally stored promo opt-ins?';
150+
confirm.title = 'Reset Promo Opt-Ins';
151+
break;
136152
case 'repositoryAccess':
137153
confirmationMessage = 'Are you sure you want to reset the repository access cache?';
138154
confirm.title = 'Reset Repository Access';
@@ -190,10 +206,19 @@ export class ResetCommand extends GlCommandBase {
190206
resetAvatarCache('all');
191207
break;
192208

209+
case 'homeSections':
210+
await this.container.storage.delete('home:sections:collapsed');
211+
await this.container.storage.delete('home:walkthrough:dismissed');
212+
break;
213+
193214
case 'integrations':
194215
await this.container.integrations.reset();
195216
break;
196217

218+
case 'promoOptIns':
219+
await this.container.storage.deleteWithPrefix('gk:promo');
220+
break;
221+
197222
case 'repositoryAccess':
198223
await this.container.git.clearAllRepoVisibilityCaches();
199224
break;

src/constants.commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,15 @@ type InternalHomeWebviewCommands =
6969

7070
type InternalHomeWebviewViewCommands =
7171
| 'gitlens.views.home.account.resync'
72+
| 'gitlens.views.home.ai.allAccess.dismiss'
7273
| 'gitlens.views.home.publishBranch'
7374
| 'gitlens.views.home.pull'
7475
| 'gitlens.views.home.push';
7576

7677
type InternalLaunchPadCommands = 'gitlens.launchpad.indicator.action';
7778

7879
type InternalPlusCommands =
80+
| 'gitlens.plus.aiAllAccess.optIn'
7981
| 'gitlens.plus.continueFeaturePreview'
8082
| 'gitlens.plus.resendVerification'
8183
| 'gitlens.plus.showPlans'

src/constants.storage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export type GlobalStorage = {
9595
>;
9696
} & {
9797
[key in `provider:authentication:skip:${string}`]: boolean;
98+
} & {
99+
[key in `gk:promo:${string}:ai:allAccess:dismissed`]: boolean;
100+
} & {
101+
[key in `gk:promo:${string}:ai:allAccess:notified`]: boolean;
98102
} & { [key in `gk:${string}:checkin`]: Stored<StoredGKCheckInResponse> } & {
99103
[key in `gk:${string}:organizations`]: Stored<StoredOrganization[]>;
100104
} & { [key in `jira:${string}:organizations`]: Stored<StoredJiraOrganization[] | undefined> } & {

src/constants.telemetry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
6666
/** Sent when switching ai models */
6767
'ai/switchModel': AISwitchModelEvent;
6868

69+
/** Sent when user dismisses the AI All Access banner */
70+
'aiAllAccess/bannerDismissed': void;
71+
72+
/** Sent when user opens the AI All Access page */
73+
'aiAllAccess/opened': void;
74+
75+
/** Sent when user opts in to AI All Access */
76+
'aiAllAccess/optedIn': void;
77+
6978
/** Sent when connecting to one or more cloud-based integrations */
7079
'cloudIntegrations/connecting': CloudIntegrationsConnectingEvent;
7180

src/plus/ai/aiProviderService.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ import { getSettledValue, getSettledValues } from '../../system/promise';
5050
import { PromiseCache } from '../../system/promiseCache';
5151
import type { ServerConnection } from '../gk/serverConnection';
5252
import { ensureFeatureAccess } from '../gk/utils/-webview/acount.utils';
53+
import { isAiAllAccessPromotionActive } from '../gk/utils/-webview/promo.utils';
5354
import { compareSubscriptionPlans, getSubscriptionPlanName, isSubscriptionPaid } from '../gk/utils/subscription.utils';
55+
import { GitKrakenProvider } from './gitkrakenProvider';
5456
import type {
5557
AIActionType,
5658
AIModel,
@@ -1107,6 +1109,11 @@ export class AIProviderService implements Disposable {
11071109
}
11081110
const cancellation = cancellationSource.token;
11091111

1112+
const isGkModel = model.provider.id === 'gitkraken';
1113+
if (isGkModel) {
1114+
await this.showAiAllAccessNotificationIfNeeded(true);
1115+
}
1116+
11101117
const confirmed = await showConfirmAIProviderToS(this.container.storage);
11111118
if (!confirmed || cancellation.isCancellationRequested) {
11121119
this.container.telemetry.sendEvent(
@@ -1184,6 +1191,10 @@ export class AIProviderService implements Disposable {
11841191
source,
11851192
);
11861193

1194+
if (!isGkModel) {
1195+
void this.showAiAllAccessNotificationIfNeeded();
1196+
}
1197+
11871198
return result;
11881199
} catch (ex) {
11891200
if (ex instanceof CancellationError) {
@@ -1542,8 +1553,69 @@ export class AIProviderService implements Disposable {
15421553
switchModel(source?: Source): Promise<AIModel | undefined> {
15431554
return this.getModel({ force: true }, source);
15441555
}
1556+
1557+
private async showAiAllAccessNotificationIfNeeded(usingGkProvider?: boolean): Promise<void> {
1558+
// Only show during the AI All Access promotion period
1559+
if (!isAiAllAccessPromotionActive()) return;
1560+
1561+
// Get current subscription to determine user ID
1562+
const subscription = await this.container.subscription.getSubscription(true);
1563+
const userId = subscription?.account?.id ?? '00000000';
1564+
1565+
// Check if notification has already been shown or if user already completed opt-in
1566+
const notificationShown = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:notified`, false);
1567+
const alreadyCompleted = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:dismissed`, false);
1568+
if (notificationShown || alreadyCompleted) return;
1569+
1570+
const hasAdvancedOrHigher = subscription.plan &&
1571+
(compareSubscriptionPlans(subscription.plan.actual.id, 'advanced') >= 0 ||
1572+
compareSubscriptionPlans(subscription.plan.effective.id, 'advanced') >= 0);
1573+
1574+
let body = 'All Access Week - now until July 11th!';
1575+
const detail = hasAdvancedOrHigher ? 'Opt in now to get unlimited GitKraken AI until July 11th!' : 'Opt in now to try all Advanced GitLens features with unlimited GitKraken AI for FREE until July 11th!';
1576+
1577+
if (!usingGkProvider) {
1578+
body += ` ${detail}`;
1579+
}
1580+
1581+
const optInButton: MessageItem = usingGkProvider ? { title: 'Opt in for Unlimited AI' } : { title: 'Opt in and Switch to GitKraken AI' };
1582+
const dismissButton: MessageItem = { title: 'No, Thanks', isCloseAffordance: true };
1583+
1584+
// Show the notification
1585+
const result = await window.showInformationMessage(body, { modal: usingGkProvider, detail: detail }, optInButton, dismissButton);
1586+
1587+
// Mark notification as shown regardless of user action
1588+
void this.container.storage.store(`gk:promo:${userId}:ai:allAccess:notified`, true);
1589+
1590+
// If user clicked the button, trigger the opt-in command
1591+
if (result === optInButton) {
1592+
void this.allAccessOptIn(usingGkProvider);
1593+
}
1594+
}
1595+
1596+
private async allAccessOptIn(usingGkProvider?: boolean): Promise<void> {
1597+
const optIn = await this.container.subscription.aiAllAccessOptIn({ source: 'notification' });
1598+
if (optIn && !usingGkProvider && isProviderEnabledByOrg('gitkraken')) {
1599+
const gkProvider = new GitKrakenProvider(this.container, this.connection);
1600+
const defaultModel = (await gkProvider.getModels()).find(m => m.default);
1601+
if (defaultModel != null) {
1602+
this._provider = gkProvider;
1603+
this._model = defaultModel;
1604+
if (isPrimaryAIProviderModel(defaultModel)) {
1605+
await configuration.updateEffective('ai.model', 'gitkraken');
1606+
await configuration.updateEffective(`ai.gitkraken.model`, defaultModel.id);
1607+
} else {
1608+
await configuration.updateEffective('ai.model', `gitkraken:${defaultModel.id}` as SupportedAIModels);
1609+
}
1610+
1611+
this._onDidChangeModel.fire({ model: defaultModel });
1612+
}
1613+
}
1614+
}
15451615
}
15461616

1617+
1618+
15471619
async function showConfirmAIProviderToS(storage: Storage): Promise<boolean> {
15481620
const confirmed = storage.get(`confirm:ai:tos`, false) || storage.getWorkspace(`confirm:ai:tos`, false);
15491621
if (confirmed) return true;

src/plus/gk/subscriptionService.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import { ensurePlusFeaturesEnabled } from './utils/-webview/plus.utils';
7474
import { getConfiguredActiveOrganizationId, updateActiveOrganizationId } from './utils/-webview/subscription.utils';
7575
import { getSubscriptionFromCheckIn } from './utils/checkin.utils';
7676
import {
77+
AiAllAccessOptInPathPrefix,
7778
assertSubscriptionState,
7879
compareSubscriptionPlans,
7980
computeSubscriptionState,
@@ -140,6 +141,7 @@ export class SubscriptionService implements Disposable {
140141
}
141142
}),
142143
container.uri.onDidReceiveSubscriptionUpdatedUri(() => this.checkUpdatedSubscription(undefined), this),
144+
container.uri.onDidReceiveAiAllAccessOptInUri(this.onAiAllAccessOptInUri, this),
143145
container.uri.onDidReceiveLoginUri(this.onLoginUri, this),
144146
);
145147

@@ -348,6 +350,7 @@ export class SubscriptionService implements Disposable {
348350
registerCommand('gitlens.plus.upgrade', (args?: SubscriptionUpgradeCommandArgs) =>
349351
this.upgrade(args?.plan, args ? { source: args.source, detail: args.detail } : undefined),
350352
),
353+
registerCommand('gitlens.plus.aiAllAccess.optIn', (src?: Source) => this.aiAllAccessOptIn(src)),
351354

352355
registerCommand('gitlens.plus.hide', (src?: Source) => this.setProFeaturesVisibility(false, src)),
353356
registerCommand('gitlens.plus.restore', (src?: Source) => this.setProFeaturesVisibility(true, src)),
@@ -1663,6 +1666,88 @@ export class SubscriptionService implements Disposable {
16631666

16641667
return this._subscription.state;
16651668
}
1669+
1670+
@log()
1671+
async aiAllAccessOptIn(source: Source | undefined): Promise<boolean> {
1672+
const scope = getLogScope();
1673+
1674+
if (!(await ensurePlusFeaturesEnabled())) return false;
1675+
1676+
const hasAccount = this._session != null;
1677+
1678+
const query = new URLSearchParams();
1679+
query.set('source', 'gitlens');
1680+
query.set('product', 'gitlens');
1681+
1682+
try {
1683+
if (hasAccount) {
1684+
try {
1685+
const token = await this.container.accountAuthentication.getExchangeToken(
1686+
AiAllAccessOptInPathPrefix,
1687+
);
1688+
query.set('token', token);
1689+
} catch (ex) {
1690+
Logger.error(ex, scope);
1691+
}
1692+
} else {
1693+
const callbackUri = await env.asExternalUri(
1694+
Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${AiAllAccessOptInPathPrefix}`),
1695+
);
1696+
query.set('redirect_uri', callbackUri.toString(true));
1697+
}
1698+
1699+
if (this.container.telemetry.enabled) {
1700+
this.container.telemetry.sendEvent('aiAllAccess/opened', undefined, source);
1701+
}
1702+
1703+
if(!(await openUrl(this.container.urls.getGkDevUrl('all-access', query)))) {
1704+
return false;
1705+
}
1706+
} catch (ex) {
1707+
Logger.error(ex, scope);
1708+
return false;
1709+
}
1710+
1711+
const completionPromises = [
1712+
new Promise<string>(resolve => setTimeout(() => resolve('cancel'), 5 * 60 * 1000)),
1713+
new Promise<string>(resolve => once(this.container.uri.onDidReceiveAiAllAccessOptInUri)(() => resolve(hasAccount ? 'update' : 'login'))),
1714+
];
1715+
1716+
const action = await Promise.race(completionPromises);
1717+
1718+
if (action === 'update' && hasAccount) {
1719+
void this.checkUpdatedSubscription(source);
1720+
void this.container.storage.store(`gk:promo:${this._session?.account.id ?? '00000000'}:ai:allAccess:dismissed`, true).catch();
1721+
void this.container.views.home.refresh();
1722+
}
1723+
1724+
if (action !== 'cancel') {
1725+
if (this.container.telemetry.enabled) {
1726+
this.container.telemetry.sendEvent('aiAllAccess/optedIn', undefined, source);
1727+
}
1728+
1729+
return true;
1730+
}
1731+
1732+
return false;
1733+
}
1734+
1735+
private async onAiAllAccessOptInUri(uri: Uri): Promise<void> {
1736+
const queryParams = new URLSearchParams(uri.query);
1737+
const code = queryParams.get('code');
1738+
1739+
if (code == null) return;
1740+
1741+
// If we don't have an account and received a code, login with the code
1742+
if (this._session == null) {
1743+
await this.loginWithCode({ code: code }, { source: 'subscription' });
1744+
const newSession = await this.getAuthenticationSession();
1745+
if (newSession?.account?.id != null) {
1746+
await this.container.storage.store(`gk:promo:${newSession.account.id}:ai:allAccess:dismissed`, true).catch();
1747+
void this.container.views.home.refresh();
1748+
}
1749+
}
1750+
}
16661751
}
16671752

16681753
function flattenFeaturePreview(preview: FeaturePreview): FeaturePreviewEventData {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function isAiAllAccessPromotionActive(): boolean {
2+
// AI All Access promotion runs from July 8th through July 12th, 2025
3+
const now = Date.now();
4+
const startDate = new Date('2025-07-07T23:59:59-00:00').getTime(); // July 8th, 2025 UTC
5+
const endDate = new Date('2025-07-12T10:00:00-00:00').getTime(); // July 12th, 2025 UTC
6+
7+
return now >= startDate && now <= endDate;
8+
}

src/plus/gk/utils/subscription.utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const orderedPlans: SubscriptionPlanIds[] = [
1818
];
1919
const orderedPaidPlans: PaidSubscriptionPlanIds[] = ['pro', 'advanced', 'teams', 'enterprise'];
2020
export const SubscriptionUpdatedUriPathPrefix = 'did-update-subscription';
21+
export const AiAllAccessOptInPathPrefix = 'ai-all-access-opt-in';
2122

2223
export function compareSubscriptionPlans(
2324
planA: SubscriptionPlanIds | undefined,

src/uris/uriService.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Event, Uri, UriHandler } from 'vscode';
22
import { Disposable, EventEmitter, window } from 'vscode';
33
import type { Container } from '../container';
44
import { AuthenticationUriPathPrefix, LoginUriPathPrefix } from '../plus/gk/authenticationConnection';
5-
import { SubscriptionUpdatedUriPathPrefix } from '../plus/gk/utils/subscription.utils';
5+
import { AiAllAccessOptInPathPrefix, SubscriptionUpdatedUriPathPrefix } from '../plus/gk/utils/subscription.utils';
66
import { CloudIntegrationAuthenticationUriPathPrefix } from '../plus/integrations/authentication/models';
77
import { log } from '../system/decorators/log';
88

@@ -30,6 +30,11 @@ export class UriService implements Disposable, UriHandler {
3030
return this._onDidReceiveSubscriptionUpdatedUri.event;
3131
}
3232

33+
private _onDidReceiveAiAllAccessOptInUri: EventEmitter<Uri> = new EventEmitter<Uri>();
34+
get onDidReceiveAiAllAccessOptInUri(): Event<Uri> {
35+
return this._onDidReceiveAiAllAccessOptInUri.event;
36+
}
37+
3338
private _onDidReceiveUri: EventEmitter<Uri> = new EventEmitter<Uri>();
3439
get onDidReceiveUri(): Event<Uri> {
3540
return this._onDidReceiveUri.event;
@@ -43,6 +48,7 @@ export class UriService implements Disposable, UriHandler {
4348
this._onDidReceiveCloudIntegrationAuthenticationUri,
4449
this._onDidReceiveLoginUri,
4550
this._onDidReceiveSubscriptionUpdatedUri,
51+
this._onDidReceiveAiAllAccessOptInUri,
4652
this._onDidReceiveUri,
4753
window.registerUriHandler(this),
4854
);
@@ -64,6 +70,9 @@ export class UriService implements Disposable, UriHandler {
6470
} else if (type === SubscriptionUpdatedUriPathPrefix) {
6571
this._onDidReceiveSubscriptionUpdatedUri.fire(uri);
6672
return;
73+
} else if (type === AiAllAccessOptInPathPrefix) {
74+
this._onDidReceiveAiAllAccessOptInUri.fire(uri);
75+
return;
6776
} else if (type === LoginUriPathPrefix) {
6877
this._onDidReceiveLoginUri.fire(uri);
6978
return;

0 commit comments

Comments
 (0)