diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs
index ab2b0e3082..b12d1c3cc9 100644
--- a/services/web/app/src/Features/Project/ProjectListController.mjs
+++ b/services/web/app/src/Features/Project/ProjectListController.mjs
@@ -27,6 +27,8 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.js'
import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
+import PermissionsManager from '../Authorization/PermissionsManager.js'
+import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
/**
* @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types"
@@ -117,7 +119,7 @@ async function projectListPage(req, res, next) {
const user = await User.findById(
userId,
`email emails features alphaProgram betaProgram lastPrimaryEmailCheck signUpDate refProviders${
- isSaas ? ' enrollment writefull completedTutorials' : ''
+ isSaas ? ' enrollment writefull completedTutorials aiErrorAssistant' : ''
}`
)
@@ -409,6 +411,25 @@ async function projectListPage(req, res, next) {
'papers-notification-banner'
)
+ await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'ai-assist-notification'
+ )
+
+ const canUseAi = await PermissionsManager.promises.checkUserPermissions(
+ user,
+ ['use-ai']
+ )
+ const assistantDisabled = user.aiErrorAssistant?.enabled === false // the assistant has been manually disabled by the user
+ const subscription =
+ await SubscriptionLocator.promises.getUsersSubscription(userId)
+ const hasManuallyCollectedSubscription =
+ subscription?.collectionMethod === 'manual'
+ const canUseAiAssist =
+ canUseAi && !hasManuallyCollectedSubscription && !assistantDisabled
+ const hasAiAssist = user.features?.aiErrorAssistant
+
const customerIoEnabled =
await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
req,
@@ -450,6 +471,7 @@ async function projectListPage(req, res, next) {
hasIndividualPaidSubscription,
userRestrictions: Array.from(req.userRestrictions || []),
customerIoEnabled,
+ showAiAssistNotification: canUseAiAssist && !hasAiAssist,
})
}
diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug
index fa7bc24c09..78103e75a6 100644
--- a/services/web/app/views/project/list-react.pug
+++ b/services/web/app/views/project/list-react.pug
@@ -86,6 +86,11 @@ block append meta
data-type='string'
content=usGovBannerVariant
)
+ meta(
+ name='ol-showAiAssistNotification'
+ data-type='boolean'
+ content=showAiAssistNotification
+ )
block content
#project-list-root
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 156ec9df3f..b404e428a5 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -420,6 +420,8 @@
"disconnected": "",
"discount": "",
"discount_of": "",
+ "discover_research_writing_toolkit": "",
+ "discover_research_writing_toolkit_description": "",
"discover_the_fastest_way_to_search_and_cite": "",
"display": "",
"display_deleted_user": "",
@@ -2098,9 +2100,13 @@
"work_in_vim_or_emacs_emulation_mode": "",
"work_offline": "",
"work_offline_pull_to_overleaf": "",
+ "work_smarter_with_ai_assist": "",
+ "work_smarter_with_ai_assist_description": "",
"work_with_non_overleaf_users": "",
"work_with_other_github_users": "",
"write_faster_smarter_with_overleaf_and_writefull_ai_tools": "",
+ "write_smarter_right_now": "",
+ "write_smarter_right_now_description": "",
"writefull": "",
"writefull_loading_error_body": "",
"writefull_loading_error_title": "",
diff --git a/services/web/frontend/js/features/project-list/components/notifications/ai-assist-banner.tsx b/services/web/frontend/js/features/project-list/components/notifications/ai-assist-banner.tsx
new file mode 100644
index 0000000000..dd6d0a0e97
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/notifications/ai-assist-banner.tsx
@@ -0,0 +1,128 @@
+import { memo, useCallback, useEffect } from 'react'
+import Notification from './notification'
+import { useTranslation } from 'react-i18next'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import { sendMB } from '@/infrastructure/event-tracking'
+import sparkle from '@/shared/svgs/sparkle-2-stars.svg'
+import { useSplitTest } from '@/shared/context/split-test-context'
+import { useProjectListContext } from '../../context/project-list-context'
+import getMeta from '@/utils/meta'
+import usePersistedState from '@/shared/hooks/use-persisted-state'
+
+function AiAssistBanner() {
+ const { title, description, cta } = useTitleDescription()
+ const { totalProjectsCount } = useProjectListContext()
+ const [dismissed, setDismissed] = usePersistedState(
+ 'ai-assist-notification-banner-dismissed',
+ false
+ )
+ const { variant } = useSplitTest('ai-assist-notification')
+ const { t } = useTranslation()
+
+ useEffect(() => {
+ if (!dismissed) {
+ sendMB('promo-prompt', {
+ location: 'dashboard-banner',
+ name: 'ai-assist',
+ variant,
+ })
+ }
+ }, [dismissed, variant])
+
+ const handleClose = useCallback(() => {
+ sendMB('promo-dismiss', {
+ location: 'dashboard-banner',
+ name: 'ai-assist',
+ variant,
+ })
+ setDismissed(true)
+ }, [setDismissed, variant])
+
+ const handleUpgradeClick = useCallback(() => {
+ sendMB('promo-click', {
+ location: 'dashboard-banner',
+ name: 'ai-assist',
+ variant,
+ type: 'click-upgrade',
+ })
+ }, [variant])
+
+ const handleLearnMoreClick = useCallback(() => {
+ sendMB('promo-click', {
+ location: 'dashboard-banner',
+ name: 'ai-assist',
+ type: 'click-learn-more',
+ variant,
+ })
+ }, [variant])
+
+ if (
+ !title ||
+ dismissed ||
+ totalProjectsCount === 0 ||
+ !getMeta('ol-showAiAssistNotification')
+ ) {
+ return null
+ }
+
+ return (
+ }
+ iconPlacement="center"
+ title={title}
+ onDismiss={handleClose}
+ content={
+
+ {description}{' '}
+
+ {t('learn_more')}
+
+ .
+
+ }
+ action={
+
+ {cta}
+
+ }
+ />
+ )
+}
+
+function useTitleDescription() {
+ const { variant } = useSplitTest('ai-assist-notification')
+ const { t } = useTranslation()
+
+ switch (variant) {
+ case 'work-smarter':
+ return {
+ title: t('work_smarter_with_ai_assist'),
+ description: t('work_smarter_with_ai_assist_description'),
+ cta: t('add_ai_assist'),
+ }
+ case 'discover-toolkit':
+ return {
+ title: t('discover_research_writing_toolkit'),
+ description: t('discover_research_writing_toolkit_description'),
+ cta: t('add_ai_assist'),
+ }
+ case 'write-smarter':
+ return {
+ title: t('write_smarter_right_now'),
+ description: t('write_smarter_right_now_description'),
+ cta: t('add_ai_assist'),
+ }
+ default:
+ return {
+ title: null,
+ description: null,
+ cta: null,
+ }
+ }
+}
+export default memo(AiAssistBanner)
diff --git a/services/web/frontend/js/features/project-list/components/notifications/notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx
index b8c399d2f2..46b53da673 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/notification.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx
@@ -3,7 +3,14 @@ import NewNotification from '@/shared/components/notification'
type NotificationProps = Pick<
React.ComponentProps,
- 'type' | 'action' | 'content' | 'onDismiss' | 'className' | 'title'
+ | 'type'
+ | 'action'
+ | 'content'
+ | 'onDismiss'
+ | 'className'
+ | 'title'
+ | 'customIcon'
+ | 'iconPlacement'
>
function Notification({ className, ...props }: NotificationProps) {
diff --git a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
index 8edb6a53cc..b21cfd28ea 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx
@@ -14,6 +14,7 @@ import {
isDeprecatedBrowser,
} from '@/shared/components/deprecated-browser'
import PapersNotificationBanner from './papers-notification-banner'
+import AiAssistBanner from './ai-assist-banner'
import { usePapersNotification } from './hooks/use-papers-notification'
const [enrollmentNotificationModule] = importOverleafModules(
@@ -57,6 +58,7 @@ function UserNotifications() {
{USGovBanner && }
{showPapersNotificationBanner && }
+
{isDeprecatedBrowser() && }
diff --git a/services/web/frontend/js/shared/components/notification.tsx b/services/web/frontend/js/shared/components/notification.tsx
index e4be7d5cab..2ebda05298 100644
--- a/services/web/frontend/js/shared/components/notification.tsx
+++ b/services/web/frontend/js/shared/components/notification.tsx
@@ -18,6 +18,7 @@ export type NotificationProps = {
className?: string
content: React.ReactNode
customIcon?: React.ReactElement | null
+ iconPlacement?: 'top' | 'center'
disclaimer?: React.ReactElement | string
isDismissible?: boolean
isActionBelowContent?: boolean
@@ -30,9 +31,11 @@ export type NotificationProps = {
export function NotificationIcon({
notificationType,
customIcon,
+ iconPlacement,
}: {
notificationType: NotificationType
customIcon?: ReactElement
+ iconPlacement?: 'top' | 'center'
}) {
let icon =
@@ -47,7 +50,16 @@ export function NotificationIcon({
} else if (notificationType === 'offer') {
icon =
}
- return {icon}
+ return (
+
+ {icon}
+
+ )
}
function Notification({
@@ -56,6 +68,7 @@ function Notification({
className = '',
content,
customIcon,
+ iconPlacement = 'top',
disclaimer,
isActionBelowContent,
isDismissible,
@@ -94,7 +107,11 @@ function Notification({
id={id}
>
{customIcon !== null && (
-
+
)}
diff --git a/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg b/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg
new file mode 100644
index 0000000000..5e4fc5e657
--- /dev/null
+++ b/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg
@@ -0,0 +1,16 @@
+
diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts
index 8e1b5bdf61..8c620d234e 100644
--- a/services/web/frontend/js/utils/meta.ts
+++ b/services/web/frontend/js/utils/meta.ts
@@ -228,6 +228,7 @@ export interface Meta {
'ol-settingsPlans': Plan[]
'ol-shouldAllowEditingDetails': boolean
'ol-shouldLoadHotjar': boolean
+ 'ol-showAiAssistNotification': boolean
'ol-showAiErrorAssistant': boolean
'ol-showBrlGeoBanner': boolean
'ol-showCouponField': boolean
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss
index ece1a465a4..c0e713c212 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss
@@ -70,6 +70,12 @@
padding: 18px $spacing-06 0 0;
}
+ .notification-icon.notification-icon-center {
+ padding-top: 0;
+ display: flex;
+ align-items: center;
+ }
+
.notification-content-and-cta {
// shared container to align cta with text on smaller screens
display: flex;
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 5c6ab42fee..f48929be4b 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -549,6 +549,8 @@
"discount": "Discount",
"discount_of": "Discount of __amount__",
"discover_latex_templates_and_examples": "Discover LaTeX templates and examples to help with everything from writing a journal article to using a specific LaTeX package.",
+ "discover_research_writing_toolkit": "NEW: Discover the ultimate research writing toolkit",
+ "discover_research_writing_toolkit_description": "Add AI Assist to get unlimited access to LaTeX-tailored AI tools from Overleaf and Writefull.",
"discover_the_fastest_way_to_search_and_cite": "Discover the fastest way to search and cite",
"discover_why_over_people_worldwide_trust_overleaf": "Discover why over __count__ million people worldwide trust Overleaf with their work.",
"display": "Display",
@@ -2655,9 +2657,13 @@
"work_offline": "Work offline",
"work_offline_pull_to_overleaf": "Work offline, then pull to __appName__",
"work_or_university_sso": "Work/university single sign-on",
+ "work_smarter_with_ai_assist": "Work smarter with AI Assist: AI tools built by TeXperts, for LaTeX",
+ "work_smarter_with_ai_assist_description": "Get help with everything from language suggestions and writing support to generating LaTeX tables and fixing errors.",
"work_with_non_overleaf_users": "Work with non Overleaf users",
"work_with_other_github_users": "Work with other GitHub users",
"write_faster_smarter_with_overleaf_and_writefull_ai_tools": "Write faster, smarter, and with confidence with Overleaf and Writefull AI tools",
+ "write_smarter_right_now": "Write smarter, right now",
+ "write_smarter_right_now_description": "Write faster and with confidence with AI Assist—LaTeX-tailored AI tools from Overleaf and Writefull.",
"writefull": "Writefull",
"writefull_loading_error_body": "Try refreshing the page. If this doesn’t work, try disabling any active browser extensions to check they aren’t blocking Writefull from loading.",
"writefull_loading_error_title": "Writefull didn’t load correctly",
diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
index abc92eefd1..f6068f106b 100644
--- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
@@ -875,7 +875,7 @@ describe('
', function () {
const modals = await screen.findAllByRole('dialog')
const modal = modals[0]
- expect(sendMBSpy).to.have.been.calledTwice
+ expect(sendMBSpy).to.have.been.calledThrice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
@@ -1014,7 +1014,7 @@ describe('
', function () {
)
).to.be.true
- expect(sendMBSpy).to.have.been.calledTwice
+ expect(sendMBSpy).to.have.been.calledThrice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
@@ -1179,7 +1179,7 @@ describe('
', function () {
await fetchMock.callHistory.flush(true)
- expect(sendMBSpy).to.have.been.calledTwice
+ expect(sendMBSpy).to.have.been.calledThrice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs
index ae1bc72210..101d3128f6 100644
--- a/services/web/test/unit/src/Project/ProjectListController.test.mjs
+++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs
@@ -145,6 +145,18 @@ describe('ProjectListController', function () {
},
}
+ ctx.PermissionsManager = {
+ promises: {
+ checkUserPermissions: sinon.stub().resolves(true),
+ },
+ }
+
+ ctx.SubscriptionLocator = {
+ promises: {
+ getUsersSubscription: sinon.stub().resolves({}),
+ },
+ }
+
vi.doMock('mongodb-legacy', () => ({
default: { ObjectId },
}))
@@ -250,6 +262,19 @@ describe('ProjectListController', function () {
default: ctx.TutorialHandler,
}))
+ vi.doMock(
+ '../../../../app/src/Features/Authorization/PermissionsManager',
+ () => ({
+ default: ctx.PermissionsManager,
+ })
+ )
+ vi.doMock(
+ '../../../../app/src/Features/Subscription/SubscriptionLocator',
+ () => ({
+ default: ctx.SubscriptionLocator,
+ })
+ )
+
ctx.ProjectListController = (await import(MODULE_PATH)).default
ctx.req = {