Skip to content

Commit ff3858f

Browse files
Adds new banner component and promo implementation (#4473)
1 parent 8227572 commit ff3858f

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
@@ -96,6 +96,10 @@ export type GlobalStorage = {
9696
>;
9797
} & {
9898
[key in `provider:authentication:skip:${string}`]: boolean;
99+
} & {
100+
[key in `gk:promo:${string}:ai:allAccess:dismissed`]: boolean;
101+
} & {
102+
[key in `gk:promo:${string}:ai:allAccess:notified`]: boolean;
99103
} & { [key in `gk:${string}:checkin`]: Stored<StoredGKCheckInResponse> } & {
100104
[key in `gk:${string}:organizations`]: Stored<StoredOrganization[]>;
101105
} & { [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,
@@ -1149,6 +1151,11 @@ export class AIProviderService implements Disposable {
11491151
}
11501152
const cancellation = cancellationSource.token;
11511153

1154+
const isGkModel = model.provider.id === 'gitkraken';
1155+
if (isGkModel) {
1156+
await this.showAiAllAccessNotificationIfNeeded(true);
1157+
}
1158+
11521159
const confirmed = await showConfirmAIProviderToS(this.container.storage);
11531160
if (!confirmed || cancellation.isCancellationRequested) {
11541161
this.container.telemetry.sendEvent(
@@ -1226,6 +1233,10 @@ export class AIProviderService implements Disposable {
12261233
source,
12271234
);
12281235

1236+
if (!isGkModel) {
1237+
void this.showAiAllAccessNotificationIfNeeded();
1238+
}
1239+
12291240
return result;
12301241
} catch (ex) {
12311242
if (ex instanceof CancellationError) {
@@ -1584,8 +1595,69 @@ export class AIProviderService implements Disposable {
15841595
switchModel(source?: Source): Promise<AIModel | undefined> {
15851596
return this.getModel({ force: true }, source);
15861597
}
1598+
1599+
private async showAiAllAccessNotificationIfNeeded(usingGkProvider?: boolean): Promise<void> {
1600+
// Only show during the AI All Access promotion period
1601+
if (!isAiAllAccessPromotionActive()) return;
1602+
1603+
// Get current subscription to determine user ID
1604+
const subscription = await this.container.subscription.getSubscription(true);
1605+
const userId = subscription?.account?.id ?? '00000000';
1606+
1607+
// Check if notification has already been shown or if user already completed opt-in
1608+
const notificationShown = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:notified`, false);
1609+
const alreadyCompleted = this.container.storage.get(`gk:promo:${userId}:ai:allAccess:dismissed`, false);
1610+
if (notificationShown || alreadyCompleted) return;
1611+
1612+
const hasAdvancedOrHigher = subscription.plan &&
1613+
(compareSubscriptionPlans(subscription.plan.actual.id, 'advanced') >= 0 ||
1614+
compareSubscriptionPlans(subscription.plan.effective.id, 'advanced') >= 0);
1615+
1616+
let body = 'All Access Week - now until July 11th!';
1617+
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!';
1618+
1619+
if (!usingGkProvider) {
1620+
body += ` ${detail}`;
1621+
}
1622+
1623+
const optInButton: MessageItem = usingGkProvider ? { title: 'Opt in for Unlimited AI' } : { title: 'Opt in and Switch to GitKraken AI' };
1624+
const dismissButton: MessageItem = { title: 'No, Thanks', isCloseAffordance: true };
1625+
1626+
// Show the notification
1627+
const result = await window.showInformationMessage(body, { modal: usingGkProvider, detail: detail }, optInButton, dismissButton);
1628+
1629+
// Mark notification as shown regardless of user action
1630+
void this.container.storage.store(`gk:promo:${userId}:ai:allAccess:notified`, true);
1631+
1632+
// If user clicked the button, trigger the opt-in command
1633+
if (result === optInButton) {
1634+
void this.allAccessOptIn(usingGkProvider);
1635+
}
1636+
}
1637+
1638+
private async allAccessOptIn(usingGkProvider?: boolean): Promise<void> {
1639+
const optIn = await this.container.subscription.aiAllAccessOptIn({ source: 'notification' });
1640+
if (optIn && !usingGkProvider && isProviderEnabledByOrg('gitkraken')) {
1641+
const gkProvider = new GitKrakenProvider(this.container, this.connection);
1642+
const defaultModel = (await gkProvider.getModels()).find(m => m.default);
1643+
if (defaultModel != null) {
1644+
this._provider = gkProvider;
1645+
this._model = defaultModel;
1646+
if (isPrimaryAIProviderModel(defaultModel)) {
1647+
await configuration.updateEffective('ai.model', 'gitkraken');
1648+
await configuration.updateEffective(`ai.gitkraken.model`, defaultModel.id);
1649+
} else {
1650+
await configuration.updateEffective('ai.model', `gitkraken:${defaultModel.id}` as SupportedAIModels);
1651+
}
1652+
1653+
this._onDidChangeModel.fire({ model: defaultModel });
1654+
}
1655+
}
1656+
}
15871657
}
15881658

1659+
1660+
15891661
async function showConfirmAIProviderToS(storage: Storage): Promise<boolean> {
15901662
const confirmed = storage.get(`confirm:ai:tos`, false) || storage.getWorkspace(`confirm:ai:tos`, false);
15911663
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)