diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 46fec3d528..5d289236f0 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1071,6 +1071,7 @@ module.exports = { ], integrationPanelComponents: [], referenceSearchSetting: [], + settingsModalEditorTabSections: [], errorLogsComponents: [], referenceIndices: [], railEntries: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5120e27348..88b06f77af 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -119,6 +119,7 @@ "ai_assist_in_overleaf_is_included_via_writefull_individual": "", "ai_assist_subscriber_can_now_write_smarter": "", "ai_assist_unavailable_due_to_subscription_type": "", + "ai_assistance": "", "ai_can_make_mistakes": "", "ai_features": "", "ai_feedback_please_provide_more_detail": "", @@ -128,6 +129,8 @@ "ai_feedback_the_suggestion_didnt_fix_the_error": "", "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "", "ai_feedback_there_was_no_code_fix_suggested": "", + "ai_shortcut_on_empty_lines": "", + "ai_shortcut_on_text_selection": "", "alignment": "", "all_borders": "", "all_events": "", @@ -1424,6 +1427,8 @@ "premium_feature": "", "premium_plan_label": "", "presentation_mode": "", + "press_shift_space_for_suggestions": "", + "press_space_to_open_the_ai_assistant": "", "preview": "", "preview_editor_tabs": "", "previous_page": "", @@ -1733,6 +1738,7 @@ "send_message": "", "send_request": "", "sending": "", + "sentence_completion": "", "server_error": "", "server_pro_license_entitlement_line_1": "", "server_pro_license_entitlement_line_2": "", diff --git a/services/web/frontend/js/features/settings/components/settings-modal-body.tsx b/services/web/frontend/js/features/settings/components/settings-modal-body.tsx index 071880cac3..3bafe7b981 100644 --- a/services/web/frontend/js/features/settings/components/settings-modal-body.tsx +++ b/services/web/frontend/js/features/settings/components/settings-modal-body.tsx @@ -1,7 +1,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap' -import { SettingsEntry } from '../context/settings-modal-context' +import { SettingsEntry } from '../context/types' import SettingsTabPane from './settings-tab-pane' import BetaBadgeIcon from '@/shared/components/beta-badge-icon' import OLTooltip from '@/shared/components/ol/ol-tooltip' diff --git a/services/web/frontend/js/features/settings/components/settings-tab-pane.tsx b/services/web/frontend/js/features/settings/components/settings-tab-pane.tsx index 5dc5932216..48c246fa10 100644 --- a/services/web/frontend/js/features/settings/components/settings-tab-pane.tsx +++ b/services/web/frontend/js/features/settings/components/settings-tab-pane.tsx @@ -1,5 +1,5 @@ import { TabPane } from 'react-bootstrap' -import { SettingsTab } from '../context/settings-modal-context' +import { SettingsTab } from '../context/types' import SettingsSection from './settings-section' import { Fragment } from 'react' diff --git a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx index 13b9f76d0d..285ee16c41 100644 --- a/services/web/frontend/js/features/settings/context/settings-modal-context.tsx +++ b/services/web/frontend/js/features/settings/context/settings-modal-context.tsx @@ -24,7 +24,6 @@ import EditorThemeSetting from '@/features/settings/components/appearance-settin import FontSizeSetting from '@/features/settings/components/appearance-settings/font-size-setting' import LineHeightSetting from '@/features/settings/components/appearance-settings/line-height-setting' import FontFamilySetting from '@/features/settings/components/appearance-settings/font-family-setting' -import { AvailableUnfilledIcon } from '@/shared/components/material-icon' import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context' import DarkModePdfSetting from '@/features/settings/components/appearance-settings/dark-mode-pdf-setting' @@ -32,41 +31,25 @@ import { useProjectSettingsContext } from '@/features/editor-left-menu/context/p import { useFeatureFlag } from '@/shared/context/split-test-context' import ProjectNotificationsSetting from '@/features/settings/components/editor-settings/project-notifications-setting' import getMeta from '@/utils/meta' +import type { + SettingsEntry, + SettingsSection, + SettingsSectionHook, +} from '@/features/settings/context/types' const [referenceSearchSettingModule] = importOverleafModules( 'referenceSearchSetting' ) const ReferenceSearchSetting = referenceSearchSettingModule?.import.default -type Setting = { - key: string - component: React.ReactNode - hidden?: boolean -} +const editorTabExtraSectionHooks: SettingsSectionHook[] = importOverleafModules( + 'settingsModalEditorTabSections' +) + .map((m: any) => m?.import?.default) + .filter((h: unknown): h is SettingsSectionHook => typeof h === 'function') -type SettingsSection = { - title?: string - key: string - settings: Setting[] -} - -export type SettingsTab = { - key: string - icon: AvailableUnfilledIcon - sections: SettingsSection[] - title: string - hidden?: boolean -} - -type SettingsLink = { - key: string - icon: AvailableUnfilledIcon - href: string - title: string - hidden?: boolean -} - -export type SettingsEntry = SettingsLink | SettingsTab +const useSlotSections = (hooks: SettingsSectionHook[]): SettingsSection[] => + hooks.map(hook => hook()).filter((s): s is SettingsSection => s != null) type SettingsModalState = { show: boolean @@ -94,6 +77,8 @@ export const SettingsModalProvider: FC = ({ const hasEmailNotifications = useFeatureFlag('email-notifications') const hasEditorTabs = useFeatureFlag('editor-tabs') + const editorTabExtraSections = useSlotSections(editorTabExtraSectionHooks) + const allSettingsTabs: SettingsEntry[] = useMemo( () => [ { @@ -168,6 +153,7 @@ export const SettingsModalProvider: FC = ({ }, ], }, + ...editorTabExtraSections, ], }, { @@ -276,7 +262,14 @@ export const SettingsModalProvider: FC = ({ hidden: !isOverleaf, }, ], - [t, overallTheme, hasEmailNotifications, isOverleaf, hasEditorTabs] + [ + t, + hasEditorTabs, + overallTheme, + hasEmailNotifications, + isOverleaf, + editorTabExtraSections, + ] ) const settingsTabs = useMemo( diff --git a/services/web/frontend/js/features/settings/context/types.ts b/services/web/frontend/js/features/settings/context/types.ts new file mode 100644 index 0000000000..ad99557e06 --- /dev/null +++ b/services/web/frontend/js/features/settings/context/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import type { AvailableUnfilledIcon } from '@/shared/components/material-icon' + +export type Setting = { + key: string + component: ReactNode + hidden?: boolean +} + +export type SettingsSection = { + title?: string + key: string + settings: Setting[] +} + +export type SettingsSectionHook = () => SettingsSection | null + +export type SettingsTab = { + key: string + icon: AvailableUnfilledIcon + sections: SettingsSection[] + title: string + hidden?: boolean +} + +export type SettingsLink = { + key: string + icon: AvailableUnfilledIcon + href: string + title: string + hidden?: boolean +} + +export type SettingsEntry = SettingsLink | SettingsTab diff --git a/services/web/frontend/js/shared/components/notification.tsx b/services/web/frontend/js/shared/components/notification.tsx index 0fe73317ca..40bbc03004 100644 --- a/services/web/frontend/js/shared/components/notification.tsx +++ b/services/web/frontend/js/shared/components/notification.tsx @@ -23,7 +23,7 @@ export type NotificationProps = { isDismissible?: boolean isActionBelowContent?: boolean onDismiss?: () => void - title?: string + title?: React.ReactNode type: NotificationType id?: string } @@ -114,11 +114,14 @@ function Notification({
- {title && ( -

- {title} -

- )} + {title && + (typeof title === 'string' ? ( +

+ {title} +

+ ) : ( + title + ))} {content}
{action &&
{action}
} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 0294b01dd6..4db0368021 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -152,6 +152,7 @@ "ai_assist_in_overleaf_is_included_via_writefull_individual": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.", "ai_assist_subscriber_can_now_write_smarter": "AI Assist subscribers can now write smarter, find citations, and generate LaTeX from prompts and images.", "ai_assist_unavailable_due_to_subscription_type": "We’re sorry—it looks like AI Assist isn’t available to you just yet due to your current subscription type.", + "ai_assistance": "AI assistance", "ai_assistant": "AI Assistant", "ai_assistant_explanation": "A LaTeX-fluent AI Assistant built into your editor.", "ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.", @@ -163,6 +164,8 @@ "ai_feedback_the_suggestion_didnt_fix_the_error": "The suggestion didn’t fix the error", "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "The suggestion wasn’t the best fix available", "ai_feedback_there_was_no_code_fix_suggested": "There was no code fix suggested", + "ai_shortcut_on_empty_lines": "AI shortcut on empty lines", + "ai_shortcut_on_text_selection": "AI shortcut on text selection", "ai_usage": "AI usage", "ai_usage_explanation": "Control AI usage globally for your organization", "alignment": "Alignment", @@ -1902,6 +1905,8 @@ "premium_plan_label": "You’re using Overleaf Premium", "presentation": "Presentation", "presentation_mode": "Presentation mode", + "press_shift_space_for_suggestions": "Press Shift+Space for suggestions", + "press_space_to_open_the_ai_assistant": "Press Space to open the AI assistant", "preview": "Preview", "preview_editor_tabs": "Preview editor tabs", "previous_24_hours_only": "previous 24 hours only", @@ -2277,6 +2282,7 @@ "send_test_email": "Send a test email", "sending": "Sending", "sent": "Sent", + "sentence_completion": "Sentence completion", "september": "September", "server_error": "Server Error", "server_pro_license_entitlement_line_1": "<0>__appName__ Server Pro license", diff --git a/services/web/test/frontend/features/settings-modal/settings-modal.test.tsx b/services/web/test/frontend/features/settings-modal/settings-modal.test.tsx index 39a042fab7..0e74d286d7 100644 --- a/services/web/test/frontend/features/settings-modal/settings-modal.test.tsx +++ b/services/web/test/frontend/features/settings-modal/settings-modal.test.tsx @@ -130,6 +130,98 @@ describe('', function () { }) }) + describe('when a user has Writefull enabled', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-writefullEnabled', true) + window.metaAttributesCache.set('ol-showAiFeatures', true) + }) + + afterEach(function () { + window.metaAttributesCache.delete('ol-writefullEnabled') + window.metaAttributesCache.delete('ol-showAiFeatures') + }) + + it('shows the AI assistance section in the Editor tab', async function () { + render( + + + + ) + + selectTab('Editor') + await waitFor(() => expect(screen.getByText('AI assistance')).to.exist) + }) + }) + + describe('when a user does not have Writefull enabled', function () { + afterEach(function () { + window.metaAttributesCache.delete('ol-writefullEnabled') + window.metaAttributesCache.delete('ol-showAiFeatures') + window.metaAttributesCache.delete('ol-cannot-use-ai') + }) + + it('does not show the AI assistance section when ol-writefullEnabled is false', async function () { + window.metaAttributesCache.set('ol-writefullEnabled', false) + window.metaAttributesCache.set('ol-showAiFeatures', true) + render( + + + + ) + + selectTab('Editor') + await waitFor( + () => expect(screen.getByLabelText('Auto-complete')).to.exist + ) + expect(screen.queryByText('AI assistance')).to.be.null + }) + + it('does not show the AI assistance section when ol-showAiFeatures is false', async function () { + window.metaAttributesCache.set('ol-writefullEnabled', true) + window.metaAttributesCache.set('ol-showAiFeatures', false) + render( + + + + ) + + selectTab('Editor') + await waitFor( + () => expect(screen.getByLabelText('Auto-complete')).to.exist + ) + expect(screen.queryByText('AI assistance')).to.be.null + }) + + it('does not show the AI assistance section when ol-cannot-use-ai is true', async function () { + window.metaAttributesCache.set('ol-writefullEnabled', true) + window.metaAttributesCache.set('ol-showAiFeatures', true) + window.metaAttributesCache.set('ol-cannot-use-ai', true) + render( + + + + ) + + selectTab('Editor') + await waitFor( + () => expect(screen.getByLabelText('Auto-complete')).to.exist + ) + expect(screen.queryByText('AI assistance')).to.be.null + }) + }) + describe('when open=project-notifications query param is present', function () { beforeEach(function () { window.metaAttributesCache.set('ol-splitTestVariants', {