Merge pull request #36140 from overleaf/mg-ai-usage-limit-show

Add 'ai-usage-limit-show' event for ai limits

GitOrigin-RevId: 887ecc8b3fea52bcd45cf771ab0d22b8f4539308
This commit is contained in:
Malik Glossop
2026-09-17 11:52:30 +00:00
committed by Copybot
parent 2566455740
commit 15bc30b596
4 changed files with 310 additions and 65 deletions
@@ -7,14 +7,23 @@ import { useTranslation } from 'react-i18next'
import { formatSecondsToHoursAndMinutes } from '@/shared/utils/time'
import getMeta from '@/utils/meta'
import { AiFeatureLocations } from './types/ai'
import { paywallTypeByLocation } from '../utils/ai-features'
import {
getAiPlanTypeWithoutUpgrade,
paywallTypeByLocation,
} from '../utils/ai-features'
function AiPaywallNotification({
isActionBelowContent = false,
featureLocation,
isVisible = true,
}: {
isActionBelowContent?: boolean
featureLocation: AiFeatureLocations
/**
* Whether the notification is on screen. Prevents analytics firing for
* messages on mounted-but-hidden surfaces (e.g. the workbench rail).
*/
isVisible?: boolean
}) {
const {
hasSuggestionsLeft,
@@ -22,6 +31,7 @@ function AiPaywallNotification({
tokenResetDate,
premiumSuggestionResetDate,
} = useEditorContext()
const hasAddOn = getMeta('ol-hasUnlimitedAi')
const user = getMeta('ol-user')
@@ -32,8 +42,6 @@ function AiPaywallNotification({
return null
}
const hasAddOn = getMeta('ol-hasUnlimitedAi')
// error assist only needs usage quota
const canUseErrorAssist = hasSuggestionsLeft
if (canUseErrorAssist && featureLocation === 'errorAssist') {
@@ -68,6 +76,7 @@ function AiPaywallNotification({
<FairUseLimit
secondsTillReset={secondsTillReset}
featureLocation={featureLocation}
isVisible={isVisible}
/>
)
}
@@ -77,6 +86,7 @@ function AiPaywallNotification({
<GroupsPaywall
secondsTillReset={secondsTillReset}
featureLocation={featureLocation}
isVisible={isVisible}
/>
)
}
@@ -86,6 +96,7 @@ function AiPaywallNotification({
<CommonsPaywall
secondsTillReset={secondsTillReset}
featureLocation={featureLocation}
isVisible={isVisible}
/>
)
}
@@ -94,6 +105,55 @@ function AiPaywallNotification({
secondsTillReset={secondsTillReset}
isActionBelowContent={isActionBelowContent}
featureLocation={featureLocation}
isVisible={isVisible}
/>
)
}
// Shared by the variants that offer no way out of the limit. Reports
// `ai-usage-limit-show`, the no-CTA counterpart to UpgradePaywall's
// `paywall-prompt`, so the two events partition every limit message shown.
function AiUsageLimitNotification({
title,
content,
featureLocation,
isVisible,
}: {
title: string
content: string
featureLocation: AiFeatureLocations
isVisible: boolean
}) {
const { sendEvent } = useEditorAnalytics()
const paywallType = paywallTypeByLocation[featureLocation]
const hasUnlimitedQuota = getMeta('ol-hasUnlimitedAi')
useEffect(() => {
if (!isVisible) {
return
}
const planType = getAiPlanTypeWithoutUpgrade(hasUnlimitedQuota)
if (!planType) {
return
}
const { planCode } = getMeta('ol-user')
sendEvent('ai-usage-limit-show', {
'paywall-type': paywallType,
'plan-type': planType,
'plan-code': planCode,
})
}, [sendEvent, paywallType, isVisible, hasUnlimitedQuota])
return (
<Notification
type="info"
title={title}
content={content}
isDismissible={false}
customIcon={null}
className="ai-paywall-notification"
/>
)
}
@@ -101,41 +161,38 @@ function AiPaywallNotification({
function GroupsPaywall({
secondsTillReset,
featureLocation,
isVisible,
}: {
secondsTillReset: number
featureLocation: AiFeatureLocations
isVisible: boolean
}) {
const { t } = useTranslation()
const message = t('your_limit_will_reset_in_time_or_speak_to_admin', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})
const title =
featureLocation === 'workbench'
? t('youve_reached_your_daily_ai_limit')
: t('youve_hit_your_daily_ai_limit')
return (
<>
<Notification
type="info"
title={title}
content={message}
isDismissible={false}
customIcon={null}
className="ai-paywall-notification"
/>
</>
<AiUsageLimitNotification
featureLocation={featureLocation}
isVisible={isVisible}
title={
featureLocation === 'workbench'
? t('youve_reached_your_daily_ai_limit')
: t('youve_hit_your_daily_ai_limit')
}
content={t('your_limit_will_reset_in_time_or_speak_to_admin', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})}
/>
)
}
function CommonsPaywall({
secondsTillReset,
featureLocation,
isVisible,
}: {
secondsTillReset: number
featureLocation: AiFeatureLocations
isVisible: boolean
}) {
const { t } = useTranslation()
@@ -144,62 +201,49 @@ function CommonsPaywall({
return null
}
const message = t('this_will_reset_in', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})
return (
<>
<Notification
type="info"
title={t('youve_reached_your_ai_usage_limit')}
content={message}
isDismissible={false}
customIcon={null}
className="ai-paywall-notification"
/>
</>
<AiUsageLimitNotification
featureLocation={featureLocation}
isVisible={isVisible}
title={t('youve_reached_your_ai_usage_limit')}
content={t('this_will_reset_in', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})}
/>
)
}
function FairUseLimit({
secondsTillReset,
featureLocation,
isVisible,
}: {
secondsTillReset: number
featureLocation: AiFeatureLocations
isVisible: boolean
}) {
const { t } = useTranslation()
const title =
featureLocation === 'workbench'
? t('usage_limit_reached')
: t('youve_reached_the_fair_usage')
const workbenchMessage = t(
'youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time',
{
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
}
)
const assistMessage = t('this_will_reset_in', {
time: formatSecondsToHoursAndMinutes(t, secondsTillReset),
})
const message =
featureLocation === 'workbench' ? workbenchMessage : assistMessage
const isWorkbench = featureLocation === 'workbench'
const time = formatSecondsToHoursAndMinutes(t, secondsTillReset)
return (
<>
<Notification
type="info"
title={title}
content={message}
isDismissible={false}
customIcon={null}
className="ai-paywall-notification"
/>
</>
<AiUsageLimitNotification
featureLocation={featureLocation}
isVisible={isVisible}
title={
isWorkbench
? t('usage_limit_reached')
: t('youve_reached_the_fair_usage')
}
content={
isWorkbench
? t(
'youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time',
{ time }
)
: t('this_will_reset_in', { time })
}
/>
)
}
@@ -207,20 +251,25 @@ function UpgradePaywall({
secondsTillReset,
isActionBelowContent,
featureLocation,
isVisible,
}: {
secondsTillReset: number
isActionBelowContent: boolean
featureLocation: AiFeatureLocations
isVisible: boolean
}) {
const { t } = useTranslation()
const { sendEvent } = useEditorAnalytics()
const paywallType = paywallTypeByLocation[featureLocation]
useEffect(() => {
if (!isVisible) {
return
}
sendEvent('paywall-prompt', {
'paywall-type': paywallType,
})
}, [sendEvent, paywallType])
}, [sendEvent, paywallType, isVisible])
return (
<Notification
@@ -1,3 +1,4 @@
import getMeta from '@/utils/meta'
import { AiFeatureLocations, PaywallType } from '../components/types/ai'
export const paywallTypeByLocation: Record<AiFeatureLocations, PaywallType> = {
@@ -5,3 +6,24 @@ export const paywallTypeByLocation: Record<AiFeatureLocations, PaywallType> = {
errorAssist: 'assistant',
citationsReviewer: 'citations-reviewer',
}
// Returns the plan type for a limit with no upgrade path, or null when an
// upgrade is offered. Membership is checked before quota so commons/group users
// with unlimited AI aren't reported as individual; group before commons matches
// the rendered paywall's precedence for users who are in both.
export function getAiPlanTypeWithoutUpgrade(
hasUnlimitedQuota: boolean
): 'commons' | 'group' | 'individual' | null {
const { hasInstitutionLicence, isMemberOfGroupSubscription } =
getMeta('ol-user')
if (isMemberOfGroupSubscription) {
return 'group'
}
if (hasInstitutionLicence) {
return 'commons'
}
if (hasUnlimitedQuota) {
return 'individual'
}
return null
}
@@ -94,6 +94,9 @@ export type EditorProvidersProps = {
| 'signUpDate'
| 'activeProfessionalGroupSubscriptions'
| 'isProfessionalGroupPlan'
| 'isMemberOfGroupSubscription'
| 'hasInstitutionLicence'
| 'planCode'
>
projectId?: string
projectName?: string
@@ -0,0 +1,171 @@
import AiPaywallNotification from '@/shared/components/ai-paywall-notification'
import { AiFeatureLocations } from '@/shared/components/types/ai'
import {
EditorProviders,
makeEditorProvider,
USER_EMAIL,
USER_ID,
type EditorProvidersProps,
} from '../../helpers/editor-providers'
type User = Partial<NonNullable<EditorProvidersProps['user']>>
const futureDate = () => new Date(Date.now() + 60 * 60 * 1000)
// These fixtures carry no planCode, so plan-type is the only thing identifying
// them in the event.
const COMMONS_USER: User = { hasInstitutionLicence: true }
const GROUP_USER: User = { isMemberOfGroupSubscription: true }
const ADD_ON_USER: User = { planCode: 'professional' }
const FAIR_USE_TITLE = 'Usage limit reached'
function mountPaywall({
user = {},
hasUnlimitedAi = false,
featureLocation = 'errorAssist',
isVisible = true,
}: {
user?: User
hasUnlimitedAi?: boolean
featureLocation?: AiFeatureLocations
isVisible?: boolean
} = {}) {
cy.window().then(win => {
win.metaAttributesCache.set('ol-showAiFeatures', true)
win.metaAttributesCache.set('ol-hasUnlimitedAi', hasUnlimitedAi)
win.metaAttributesCache.get('ol-ExposedSettings').isOverleaf = true
})
cy.mount(
<EditorProviders
user={{ id: USER_ID, email: USER_EMAIL, ...user }}
providers={{
EditorProvider: makeEditorProvider({
hasSuggestionsLeft: false,
hasTokensLeft: false,
premiumSuggestionResetDate: futureDate(),
tokenResetDate: futureDate(),
}),
}}
>
<AiPaywallNotification
featureLocation={featureLocation}
isVisible={isVisible}
/>
</EditorProviders>
)
}
describe('<AiPaywallNotification />', function () {
beforeEach(function () {
cy.intercept('POST', '/event/ai-usage-limit-show', { statusCode: 204 }).as(
'limitShown'
)
cy.intercept('POST', '/event/paywall-prompt', { statusCode: 204 }).as(
'paywallPrompt'
)
})
it('reports ai-usage-limit-show for a commons user', function () {
mountPaywall({ user: COMMONS_USER })
cy.wait('@limitShown').then(({ request }) => {
expect(request.body).to.deep.include({
'paywall-type': 'assistant',
'plan-type': 'commons',
})
expect(request.body).not.to.have.property('plan-code')
})
})
// A commons entitlement can grant unlimited AI, which renders the fair-use
// copy. The user is still commons and must be reported as such.
it('reports a commons user as commons when their plan grants unlimited AI', function () {
mountPaywall({
user: COMMONS_USER,
hasUnlimitedAi: true,
featureLocation: 'workbench',
})
cy.contains(FAIR_USE_TITLE).should('be.visible')
cy.wait('@limitShown').then(({ request }) => {
expect(request.body).to.deep.include({
'paywall-type': 'workbench',
'plan-type': 'commons',
})
})
})
it('reports ai-usage-limit-show for a group member', function () {
mountPaywall({ user: GROUP_USER })
cy.wait('@limitShown').then(({ request }) => {
expect(request.body).to.deep.include({
'paywall-type': 'assistant',
'plan-type': 'group',
})
expect(request.body).not.to.have.property('plan-code')
})
})
it('reports ai-usage-limit-show for a user on the AI add-on', function () {
mountPaywall({
user: ADD_ON_USER,
hasUnlimitedAi: true,
featureLocation: 'workbench',
})
cy.wait('@limitShown').then(({ request }) => {
expect(request.body).to.deep.include({
'paywall-type': 'workbench',
'plan-code': 'professional',
'plan-type': 'individual',
})
})
})
it('reports paywall-prompt, not ai-usage-limit-show, when an upgrade is offered', function () {
mountPaywall({ user: { planCode: 'personal' } })
cy.wait('@paywallPrompt')
cy.get('@limitShown.all').should('have.length', 0)
})
// The workbench rail and its tab panes stay mounted while hidden, so the
// banner exists long before anyone sees it.
it('does not report a limit message the user cannot see', function () {
mountPaywall({
user: COMMONS_USER,
featureLocation: 'workbench',
hasUnlimitedAi: true,
isVisible: false,
})
cy.contains(FAIR_USE_TITLE).should('be.visible')
cy.get('@limitShown.all').should('have.length', 0)
})
it('does not report a paywall prompt the user cannot see', function () {
mountPaywall({
user: { planCode: 'personal' },
featureLocation: 'workbench',
isVisible: false,
})
// Proves the upgrade variant rendered, so the absent event is the gate
// rather than the component having taken some other branch.
cy.findByRole('link', { name: 'Upgrade' }).should('be.visible')
cy.get('@paywallPrompt.all').should('have.length', 0)
})
it('reports nothing for a commons user in the workbench, where no message is shown', function () {
mountPaywall({ user: COMMONS_USER, featureLocation: 'workbench' })
cy.contains('You’ve reached your AI usage limit').should('not.exist')
cy.get('@limitShown.all').should('have.length', 0)
cy.get('@paywallPrompt.all').should('have.length', 0)
})
})