[web] add clsi-cache prompts (#29281)

* [web] add clsi-cache prompts

* [web] add new editor variant to segmentation

* [web] add tests for useNewEditorVariant

* [web] adjust start of using clsi-cache in split-test

GitOrigin-RevId: c9c5b1eff2ceefb65ef82516d9074cb971cb4c48
This commit is contained in:
Jakob Ackermann
2025-10-23 16:33:22 +02:00
committed by Copybot
parent d0d360c1bd
commit f3f6a8a8e0
23 changed files with 576 additions and 19 deletions
@@ -74,8 +74,9 @@ async function _getSplitTestOptions(req, res) {
'populate-clsi-cache'
)
let populateClsiCache = populateClsiCacheVariant === 'enabled'
const compileFromClsiCache = populateClsiCache // use same split-test
let compileFromClsiCache = populateClsiCache // use same split-test
let clsiCachePromptVariant = 'default'
if (!populateClsiCache) {
// Pre-populate the cache for the users in the split-test for prompts.
// Keep the compile from cache disabled for now.
@@ -84,7 +85,17 @@ async function _getSplitTestOptions(req, res) {
res,
'populate-clsi-cache-for-prompt'
)
;({ variant: clsiCachePromptVariant } =
await SplitTestHandler.promises.getAssignment(
editorReq,
res,
'clsi-cache-prompt'
))
populateClsiCache = variant === 'enabled'
if (res.locals.splitTestInfo?.['clsi-cache-prompt']?.active) {
// Start using the cache when the split-test for the prompts is activated.
compileFromClsiCache = populateClsiCache
}
}
const pdfDownloadDomain = Settings.pdfDownloadDomain
@@ -94,6 +105,7 @@ async function _getSplitTestOptions(req, res) {
return {
compileFromClsiCache,
populateClsiCache,
clsiCachePromptVariant,
pdfDownloadDomain,
enablePdfCaching: false,
}
@@ -113,6 +125,7 @@ async function _getSplitTestOptions(req, res) {
return {
compileFromClsiCache,
populateClsiCache,
clsiCachePromptVariant,
pdfDownloadDomain,
enablePdfCaching: false,
}
@@ -121,6 +134,7 @@ async function _getSplitTestOptions(req, res) {
return {
compileFromClsiCache,
populateClsiCache,
clsiCachePromptVariant,
pdfDownloadDomain,
enablePdfCaching,
pdfCachingMinChunkSize,
@@ -212,6 +226,7 @@ const _CompileController = {
let {
compileFromClsiCache,
populateClsiCache,
clsiCachePromptVariant,
enablePdfCaching,
pdfCachingMinChunkSize,
pdfDownloadDomain,
@@ -294,6 +309,7 @@ const _CompileController = {
compileGroup: limits?.compileGroup,
clsiServerId,
clsiCacheShard,
clsiCachePromptVariant,
validationProblems,
stats,
timings,
@@ -386,6 +386,7 @@ const _ProjectController = {
'external-socket-heartbeat',
'null-test-share-modal',
'populate-clsi-cache',
'populate-clsi-cache-for-prompt',
'pdf-caching-cached-url-lookup',
'pdf-caching-mode',
'pdf-caching-prefetch-large',
@@ -818,14 +819,26 @@ const _ProjectController = {
const planDetails = Settings.plans.find(p => p.planCode === planCode)
const projectOwnerHasPremiumOnPageLoad =
ownerFeatures?.compileGroup === 'priority'
if (
projectOwnerHasPremiumOnPageLoad &&
splitTestAssignments['populate-clsi-cache']?.variant !== 'enabled'
) {
await SplitTestHandler.promises.getAssignment(
req,
res,
'clsi-cache-prompt'
)
}
res.render(template, {
title: project.name,
priority_title: true,
bodyClasses: ['editor'],
project_id: project._id,
projectName: project.name,
projectOwnerHasPremiumOnPageLoad:
ownerFeatures?.compileGroup === 'priority',
projectOwnerHasPremiumOnPageLoad,
user: {
id: userId,
email: user.email,
@@ -278,6 +278,19 @@
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
"clone_with_git": "",
"close": "",
"clsi_cache_prompt_compile_faster": "",
"clsi_cache_prompt_compile_question": "",
"clsi_cache_prompt_compile_same": "",
"clsi_cache_prompt_compile_slower": "",
"clsi_cache_prompt_preview_less": "",
"clsi_cache_prompt_preview_more": "",
"clsi_cache_prompt_preview_question": "",
"clsi_cache_prompt_preview_same": "",
"clsi_cache_prompt_synctex_less": "",
"clsi_cache_prompt_synctex_more": "",
"clsi_cache_prompt_synctex_question": "",
"clsi_cache_prompt_synctex_same": "",
"clsi_cache_prompt_thanks": "",
"clsi_maintenance": "",
"clsi_unavailable": "",
"code_check_failed": "",
@@ -18,6 +18,7 @@ import PdfClearCacheButton from '@/features/pdf-preview/components/pdf-clear-cac
import PdfDownloadFilesButton from '@/features/pdf-preview/components/pdf-download-files-button'
import { useIsNewErrorLogsPositionEnabled } from '../../utils/new-editor-utils'
import RollingBuildSelectedReminder from './rolling-build-selected-reminder'
import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
const logsComponents: Array<{
import: { default: ElementType }
@@ -86,6 +87,7 @@ function ErrorLogs({
))}
<TabContent className="error-logs new-error-logs">
<div className="logs-pane-content">
<ClsiCachePrompt />
<RollingBuildSelectedReminder />
{stoppedOnFirstError && includeErrors && <StopOnFirstErrorPrompt />}
@@ -2,6 +2,7 @@ import { Trans, useTranslation } from 'react-i18next'
import ErrorState from './error-state'
import OLButton from '@/shared/components/ol/ol-button'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
export default function RenderingErrorExpectedState() {
const { t } = useTranslation()
@@ -30,6 +31,7 @@ export default function RenderingErrorExpectedState() {
{t('recompile')}
</OLButton>,
]}
extraContent={<ClsiCachePrompt />}
/>
)
}
@@ -86,3 +86,13 @@ export const useAreNewErrorLogsEnabled = () => {
const newEditorEnabled = useIsNewEditorEnabled()
return newEditorEnabled && canUseNewLogs()
}
export function useNewEditorVariant() {
const newEditor = useIsNewEditorEnabled()
const newErrorLogs = useAreNewErrorLogsEnabled()
const newErrorLogsPosition = useIsNewErrorLogsPositionEnabled()
if (!newEditor) return 'default'
if (!newErrorLogs) return 'new-editor-old-logs'
if (!newErrorLogsPosition) return 'new-editor-new-logs-old-position'
return 'new-editor'
}
@@ -0,0 +1,146 @@
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { sendMB } from '../../../infrastructure/event-tracking'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useProjectContext } from '../../../shared/context/project-context'
import OLNotification from '@/shared/components/ol/ol-notification'
import OLButton from '@/shared/components/ol/ol-button'
import '../../../../stylesheets/pages/editor/clsi-cache-prompt.scss'
import { fallBackToClsiCache } from '@/features/pdf-preview/util/pdf-caching-flags'
import { useNewEditorVariant } from '@/features/ide-redesign/utils/new-editor-utils'
const SAY_THANKS_TIMEOUT = 10 * 1000
function ClsiCachePromptContent() {
const { clsiCachePromptVariant, clsiCachePromptSegmentation } =
useCompileContext()
const { projectId } = useProjectContext()
const newEditorVariant = useNewEditorVariant()
const [hasRatedProject, setHasRatedProject] = usePersistedState(
`clsi-cache-prompt:${clsiCachePromptVariant}:${projectId}`,
false,
{ listen: true }
)
const [dismiss, setDismiss] = usePersistedState(
`clsi-cache-prompt:dismiss`,
false,
{ listen: true }
)
const [sayThanks, setSayThanks] = useState(false)
function sendEvent(feedback: string) {
sendMB('clsi-cache-prompt', {
projectId,
clsiCacheEnabled: fallBackToClsiCache,
clsiCachePromptVariant,
newEditorVariant,
...clsiCachePromptSegmentation[clsiCachePromptVariant],
feedback,
})
}
function submitFeedback(feedback: string) {
sendEvent(feedback)
setHasRatedProject(true)
setSayThanks(true)
window.setTimeout(() => {
setSayThanks(false)
}, SAY_THANKS_TIMEOUT)
}
function dismissFeedback() {
sendEvent('dismiss')
setDismiss(true)
}
const { t } = useTranslation()
if (clsiCachePromptVariant === 'default') return null
switch (true) {
case sayThanks:
return (
<OLNotification
type="info"
className="clsi-cache-prompt"
onDismiss={() => setSayThanks(false)}
content={t('clsi_cache_prompt_thanks')}
/>
)
case dismiss || hasRatedProject:
return null
case clsiCachePromptSegmentation?.[clsiCachePromptVariant] != null: {
let question: string
let answers: Record<string, string>
switch (clsiCachePromptVariant) {
case 'compile': {
question = t('clsi_cache_prompt_compile_question')
answers = {
slower: t('clsi_cache_prompt_compile_slower'),
same: t('clsi_cache_prompt_compile_same'),
faster: t('clsi_cache_prompt_compile_faster'),
}
break
}
case 'preview':
case 'preview-error': {
question = t('clsi_cache_prompt_preview_question')
answers = {
less: t('clsi_cache_prompt_preview_less'),
same: t('clsi_cache_prompt_preview_same'),
more: t('clsi_cache_prompt_preview_more'),
}
break
}
case 'synctex': {
question = t('clsi_cache_prompt_synctex_question')
answers = {
less: t('clsi_cache_prompt_synctex_less'),
same: t('clsi_cache_prompt_synctex_same'),
more: t('clsi_cache_prompt_synctex_more'),
}
break
}
}
return (
<OLNotification
type="info"
className="clsi-cache-prompt"
customIcon={<></>}
isDismissible
onDismiss={dismissFeedback}
content={
<>
{question}
<br />
{Object.entries(answers).map(([feedback, text]) => (
<OLButton
variant="secondary"
size="sm"
onClick={() => submitFeedback(feedback)}
key={feedback}
>
{text}
</OLButton>
))}
</>
}
/>
)
}
default:
return null
}
}
function ClsiCachePrompt() {
const { clsiCachePromptVariant, showLogs } = useCompileContext()
const onlyInLogsPane = clsiCachePromptVariant === 'preview-error'
if (clsiCachePromptVariant === 'default' || showLogs !== onlyInLogsPane) {
return null
}
return <ClsiCachePromptContent />
}
export default memo(ClsiCachePrompt)
@@ -25,8 +25,14 @@ type PdfJsViewerProps = {
function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const { projectId } = useProjectContext()
const { setError, firstRenderDone, highlights, position, setPosition } =
useCompileContext()
const {
setError,
firstRenderDone,
highlights,
position,
setPosition,
setClsiCachePromptSegmentation,
} = useCompileContext()
const { setLoadingError } = usePdfPreviewContext()
@@ -82,6 +88,10 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
}, [])
const [startFetch, setStartFetch] = useState(0)
const startFetchRef = useRef(startFetch)
useEffect(() => {
startFetchRef.current = startFetch
}, [startFetch])
// listen for events and trigger rendering.
// Do everything in one effect to mitigate de-sync between events.
@@ -148,6 +158,27 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
setPage(event.pageNumber)
}
const handleRenderedClsiCachePrompt = () => {
const delay = Math.ceil(
(performance.now() - startFetchRef.current) / 1_000
)
if (delay > 30) {
setClsiCachePromptSegmentation(prev => {
// Always overwrite segmentation; emit the greatest delay.
return {
...prev,
preview: {
previewFailed: false,
delay,
usedClsiCache: pdfJsWrapper.usedClsiCache(),
},
}
})
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedClsiCachePrompt)
}
}
pdfJsWrapper.eventBus.on('pagerendered', handleRenderedClsiCachePrompt)
// `pagesinit` fires when the data for rendering the first page is ready.
pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
// `pagerendered` fires when a page was actually rendered.
@@ -164,15 +195,22 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber)
pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged)
pdfJsWrapper.eventBus.off('pagechanging', handlePageChanging)
pdfJsWrapper.eventBus.off('pagerendered', handleRenderedClsiCachePrompt)
}
}, [pdfJsWrapper, firstRenderDone, startFetch])
}, [
pdfJsWrapper,
firstRenderDone,
startFetch,
setClsiCachePromptSegmentation,
])
// load the PDF document from the URL
useEffect(() => {
if (pdfJsWrapper && url) {
setInitialised(false)
setError(undefined)
setStartFetch(performance.now())
const t0 = performance.now()
setStartFetch(t0)
const abortController = new AbortController()
const handleFetchError = (err: any) => {
@@ -180,6 +218,21 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
// The error is already logged at the call-site with additional context.
if (err instanceof PDFJS.ResponseException && err.missing) {
setError('rendering-error-expected')
setClsiCachePromptSegmentation(prev => {
if (prev['preview-error'] !== null) {
return prev // keep delay segmentation from first error.
}
const delay = Math.ceil((performance.now() - t0) / 1_000)
return {
...prev,
preview: null,
'preview-error': {
previewFailed: true,
delay,
usedClsiCache: pdfJsWrapper.usedClsiCache(),
},
}
})
} else {
setError('rendering-error')
}
@@ -190,6 +243,9 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
if (doc) {
setTotalPages(doc.numPages)
}
setClsiCachePromptSegmentation(prev => {
return { ...prev, preview: null, 'preview-error': null }
})
})
.catch(error => {
if (abortController.signal.aborted) return
@@ -200,7 +256,14 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
abortController.abort()
}
}
}, [pdfJsWrapper, url, pdfFile, setError, setStartFetch])
}, [
pdfJsWrapper,
url,
pdfFile,
setError,
setStartFetch,
setClsiCachePromptSegmentation,
])
// listen for scroll events
useEffect(() => {
@@ -16,6 +16,7 @@ import { useDetachCompileContext as useCompileContext } from '../../../shared/co
import PdfLogEntry from './pdf-log-entry'
import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
import getMeta from '@/utils/meta'
import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
const {
@@ -26,6 +27,7 @@ function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
validationIssues,
showLogs,
stoppedOnFirstError,
clsiCachePromptVariant,
} = useCompileContext()
const { loadingError } = usePdfPreviewContext()
@@ -42,6 +44,8 @@ function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
data-testid="logs-pane"
>
<div className="logs-pane-content">
{clsiCachePromptVariant === 'preview-error' && <ClsiCachePrompt />}
<RollingBuildSelectedReminder />
{codeCheckFailed && <PdfCodeCheckFailedNotice />}
@@ -19,6 +19,7 @@ import importOverleafModules from '../../../../macros/import-overleaf-module.mac
import PdfCodeCheckFailedBanner from '@/features/ide-redesign/components/pdf-preview/pdf-code-check-failed-banner'
import getMeta from '@/utils/meta'
import NewPdfLogsViewer from '@/features/ide-redesign/components/pdf-preview/pdf-logs-viewer'
import ClsiCachePrompt from './clsi-cache-prompt'
function PdfPreviewPane() {
const { pdfUrl } = useCompileContext()
@@ -50,6 +51,7 @@ function PdfPreviewPane() {
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer" data-testid="pdf-viewer">
<PdfViewer />
<ClsiCachePrompt />
</div>
</Suspense>
{newErrorLogsPosition ? (
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { useProjectContext } from '../../../shared/context/project-context'
import { getJSON } from '../../../infrastructure/fetch-json'
import { FetchError, getJSON } from '../../../infrastructure/fetch-json'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import useAbortController from '../../../shared/hooks/use-abort-controller'
@@ -33,8 +33,14 @@ export default function useSynctex(): {
const { projectId, project } = useProjectContext()
const rootDocId = project?.rootDocId
const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
useCompileContext()
const {
clsiServerId,
pdfFile,
position,
setShowLogs,
setHighlights,
setClsiCachePromptSegmentation,
} = useCompileContext()
const { selectedEntities } = useFileTreeData()
const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
@@ -121,6 +127,16 @@ export default function useSynctex(): {
.then(data => {
setShowLogs(false)
setHighlights(data.pdf)
setClsiCachePromptSegmentation(prev => {
return {
...prev,
synctex: {
direction: 'pdf',
navigationFailed: false,
restoredFromCache: data.downloadedFromCache,
},
}
})
if (data.downloadedFromCache) {
sendMB('synctex-downloaded-from-cache', {
projectId,
@@ -129,6 +145,17 @@ export default function useSynctex(): {
}
})
.catch(error => {
if (error instanceof FetchError && error.response?.status === 404) {
setClsiCachePromptSegmentation(prev => {
return {
...prev,
synctex: {
direction: 'pdf',
navigationFailed: true,
},
}
})
}
showSynctexRequestErrorToast()
debugConsole.error(error)
})
@@ -146,6 +173,7 @@ export default function useSynctex(): {
setShowLogs,
setHighlights,
setSyncToPdfInFlight,
setClsiCachePromptSegmentation,
signal,
]
)
@@ -235,6 +263,16 @@ export default function useSynctex(): {
.then(data => {
const [{ file, line }] = data.code
goToCodeLine(file, line, selectText)
setClsiCachePromptSegmentation(prev => {
return {
...prev,
synctex: {
direction: 'code',
navigationFailed: false,
restoredFromCache: data.downloadedFromCache,
},
}
})
if (data.downloadedFromCache) {
sendMB('synctex-downloaded-from-cache', {
projectId,
@@ -243,6 +281,17 @@ export default function useSynctex(): {
}
})
.catch(error => {
if (error instanceof FetchError && error.response?.status === 404) {
setClsiCachePromptSegmentation(prev => {
return {
...prev,
synctex: {
direction: 'code',
navigationFailed: true,
},
}
})
}
debugConsole.error(error)
showSynctexRequestErrorToast()
})
@@ -259,6 +308,7 @@ export default function useSynctex(): {
signal,
isMounted,
setSyncToCodeInFlight,
setClsiCachePromptSegmentation,
goToCodeLine,
]
)
@@ -153,6 +153,7 @@ export default class DocumentCompiler {
)
const compileTimeClientE2E = Math.ceil(performance.now() - t0)
if (data.timings) data.timings.compileTimeClientE2E = compileTimeClientE2E
const { deliveryLatencies, firstRenderDone } = trackPdfDownload(
data,
compileTimeClientE2E,
@@ -33,4 +33,6 @@ export const projectOwnerHasPremiumOnPageLoad = getMeta(
'ol-projectOwnerHasPremiumOnPageLoad'
)
export const fallBackToClsiCache =
projectOwnerHasPremiumOnPageLoad && isFlagEnabled('populate-clsi-cache')
projectOwnerHasPremiumOnPageLoad &&
(isFlagEnabled('populate-clsi-cache') ||
isFlagEnabled('populate-clsi-cache-for-prompt'))
@@ -48,6 +48,10 @@ export default class PDFJSWrapper {
this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
}
usedClsiCache() {
return !!this.pdfCachingTransportFactory?.sentEventFallbackToClsiCache
}
// load a document from a URL
async loadDocument({
url,
@@ -42,6 +42,17 @@ export type PdfFileDataList = {
archive?: PdfFileArchiveData
}
export type ClsiCachePromptVariant =
| 'default'
| 'compile'
| 'preview'
| 'preview-error'
| 'synctex'
export type ClsiCachePromptSegmentation = Record<
ClsiCachePromptVariant,
Record<string, any> | null
>
export type HighlightData = {
page: number
h: number
@@ -56,6 +56,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
setStopOnValidationError: _setStopOnValidationError,
showLogs: _showLogs,
showCompileTimeWarning: _showCompileTimeWarning,
clsiCachePromptVariant: _clsiCachePromptVariant,
clsiCachePromptSegmentation: _clsiCachePromptSegmentation,
setClsiCachePromptSegmentation: _setClsiCachePromptSegmentation,
stopOnFirstError: _stopOnFirstError,
stopOnValidationError: _stopOnValidationError,
stoppedOnFirstError: _stoppedOnFirstError,
@@ -201,6 +204,24 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher',
'detached'
)
const [clsiCachePromptVariant] = useDetachStateWatcher(
'clsiCachePromptVariant',
_clsiCachePromptVariant,
'detacher',
'detached'
)
const [clsiCachePromptSegmentation] = useDetachStateWatcher(
'clsiCachePromptSegmentation',
_clsiCachePromptSegmentation,
'detacher',
'detached'
)
const setClsiCachePromptSegmentation = useDetachAction(
'setClsiCachePromptSegmentation',
_setClsiCachePromptSegmentation,
'detacher',
'detached'
)
const [stopOnFirstError] = useDetachStateWatcher(
'stopOnFirstError',
_stopOnFirstError,
@@ -419,6 +440,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
setStopOnValidationError,
showLogs,
showCompileTimeWarning,
clsiCachePromptVariant,
clsiCachePromptSegmentation,
setClsiCachePromptSegmentation,
startCompile,
stopCompile,
stopOnFirstError,
@@ -472,6 +496,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
setStopOnValidationError,
showCompileTimeWarning,
showLogs,
clsiCachePromptVariant,
clsiCachePromptSegmentation,
setClsiCachePromptSegmentation,
startCompile,
stopCompile,
stopOnFirstError,
@@ -39,18 +39,20 @@ import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { getJSON } from '@/infrastructure/fetch-json'
import { CompileResponseData } from '../../../../types/compile'
import { CompileResponseData, PDFFile } from '../../../../types/compile'
import {
PdfScrollPosition,
usePdfScrollPosition,
} from '@/shared/hooks/use-pdf-scroll-position'
import {
ClsiCachePromptSegmentation,
ClsiCachePromptVariant,
DeliveryLatencies,
HighlightData,
LogEntry,
PdfFileDataList,
} from '@/features/pdf-preview/util/types'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { getSplitTestVariant, isSplitTestEnabled } from '@/utils/splitTestUtils'
import { captureException } from '@/infrastructure/error-reporter'
import OError from '@overleaf/o-error'
import getMeta from '@/utils/meta'
@@ -99,6 +101,11 @@ export type CompileContext = {
setStopOnValidationError: (value: boolean) => void
showCompileTimeWarning: boolean
showLogs: boolean
clsiCachePromptVariant: ClsiCachePromptVariant
clsiCachePromptSegmentation: ClsiCachePromptSegmentation
setClsiCachePromptSegmentation: Dispatch<
SetStateAction<ClsiCachePromptSegmentation>
>
stopOnFirstError: boolean
stopOnValidationError: boolean
stoppedOnFirstError: boolean
@@ -200,7 +207,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
// fetch initial compile response from cache
const [initialCompileFromCache, setInitialCompileFromCache] = useState(
getMeta('ol-projectOwnerHasPremiumOnPageLoad') &&
isSplitTestEnabled('populate-clsi-cache') &&
(isSplitTestEnabled('populate-clsi-cache') ||
isSplitTestEnabled('populate-clsi-cache-for-prompt')) &&
// Avoid fetching the initial compile from cache in PDF detach tab
role !== 'detached'
)
@@ -209,6 +217,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
useState(false)
// Raw data from clsi-cache, will need post-processing and check settings
const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
const [compileFromCacheStartedAt, setCompileFromCacheStartedAt] = useState(0)
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
@@ -216,6 +225,18 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
// flags for clsi-cache prompt
const [clsiCachePromptVariant, setClsiCachePromptVariant] =
useState<ClsiCachePromptVariant>('default')
const [clsiCachePromptSegmentation, setClsiCachePromptSegmentation] =
useState<ClsiCachePromptSegmentation>({
default: null,
compile: null,
preview: null,
'preview-error': null,
synctex: null,
})
// whether the compile dropdown arrow should be animated
const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
useState(false)
@@ -356,6 +377,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
useEffect(() => {
if (initialCompileFromCache && !pendingInitialCompileFromCache) {
setPendingInitialCompileFromCache(true)
setCompileFromCacheStartedAt(performance.now())
getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
.then((data: any) => {
// Hand data over to next effect, it will wait for project/doc loading.
@@ -401,6 +423,14 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
if (settingsUpToDate) {
sendMB('compile-from-cache', { projectId })
dataFromCache.clsiCachePromptVariant = getSplitTestVariant(
'clsi-cache-prompt',
'default'
) as ClsiCachePromptVariant
if (!dataFromCache.timings) dataFromCache.timings = {}
dataFromCache.timings.compileTimeClientE2E = Math.ceil(
performance.now() - compileFromCacheStartedAt
)
setData(dataFromCache)
setCompiledOnce(true)
}
@@ -409,6 +439,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
setPendingInitialCompileFromCache(false)
}, [
projectId,
compileFromCacheStartedAt,
dataFromCache,
joinedOnce,
currentDocument,
@@ -486,6 +517,32 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
if (data.clsiServerId) {
setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
}
setClsiCachePromptVariant(data.clsiCachePromptVariant ?? 'default')
setClsiCachePromptSegmentation(prev => {
if (
!(
data.status === 'success' &&
(data.fromCache || data.stats?.isInitialCompile === 1)
)
) {
return { ...prev, compile: null }
}
const pdfSize = (
data.outputFiles.find(f => f.path === 'output.pdf') as PDFFile
)?.size
return {
...prev,
compile: {
pdfSize,
isCompileFromCache: Boolean(data.fromCache),
isInitialCompile: data.stats?.isInitialCompile === 1,
restoredClsiCache: data.stats?.restoredClsiCache === 1,
compileTimeServerE2E: data.timings?.compileE2E,
compileTimeClientE2E: data.timings?.compileTimeClientE2E,
clsiCacheEnabled: Boolean(data.clsiCacheShard),
},
}
})
if (data.outputFiles) {
const outputFiles = new Map()
@@ -781,6 +838,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
setStopOnFirstError,
setStopOnValidationError,
showLogs,
clsiCachePromptVariant,
clsiCachePromptSegmentation,
setClsiCachePromptSegmentation,
startCompile,
stopCompile,
stopOnFirstError,
@@ -831,6 +891,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
setStopOnValidationError,
showCompileTimeWarning,
showLogs,
clsiCachePromptVariant,
clsiCachePromptSegmentation,
setClsiCachePromptSegmentation,
startCompile,
stopCompile,
stopOnFirstError,
@@ -0,0 +1,27 @@
@import '../../foundations/spacing';
.clsi-cache-prompt {
width: auto; // Do not expand to full width.
.pdf-viewer &,
.pdf-error-state & {
position: fixed;
bottom: 0;
right: 0.5rem; // Add space for scrollbar.
margin: 1rem;
}
.notification-icon {
display: none; // Hide the (i) icon.
}
.notification-close-btn {
// Put even spacing between question, X and notification border.
padding: 0 0 0 (16px - 5.5px); // The X button already has 5.5px padding.
}
.btn {
white-space: nowrap;
margin: 10px 0 0 10px;
}
}
+13
View File
@@ -355,6 +355,19 @@
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete</0> will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your group settings.",
"clone_with_git": "Clone with Git",
"close": "Close",
"clsi_cache_prompt_compile_faster": "Faster",
"clsi_cache_prompt_compile_question": "Was this compile different than usual?",
"clsi_cache_prompt_compile_same": "Same",
"clsi_cache_prompt_compile_slower": "Slower",
"clsi_cache_prompt_preview_less": "Less reliable",
"clsi_cache_prompt_preview_more": "More reliable",
"clsi_cache_prompt_preview_question": "How are you finding the PDF preview at the moment?",
"clsi_cache_prompt_preview_same": "Same as usual",
"clsi_cache_prompt_synctex_less": "Less reliable",
"clsi_cache_prompt_synctex_more": "More reliable",
"clsi_cache_prompt_synctex_question": "How are you finding the navigation between text and PDF?",
"clsi_cache_prompt_synctex_same": "Same as usual",
"clsi_cache_prompt_thanks": "Thanks for the feedback!",
"clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.",
"clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",
"cn": "Chinese (Simplified)",
@@ -0,0 +1,77 @@
import { expect } from 'chai'
import { mockScope } from './scope'
import { EditorProviders } from '../../helpers/editor-providers'
import { renderHook } from '@testing-library/react'
import { useNewEditorVariant } from '@/features/ide-redesign/utils/new-editor-utils'
describe('new-editor-utils', function () {
describe('useNewEditorVariant', function () {
const newEditorVariants = [
'default',
'new-editor',
'new-editor-old-logs',
'new-editor-new-logs-old-position',
]
for (const variant of newEditorVariants) {
it(`forwards ?editor-redesign-new-users=${variant}`, function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'editor-redesign-new-users': variant,
})
const scope = mockScope()
const { result } = renderHook(() => useNewEditorVariant(), {
wrapper: ({ children }) => (
<EditorProviders
scope={scope}
userSettings={{ enableNewEditor: true }}
>
{children}
</EditorProviders>
),
})
expect(result.current).to.equal(variant)
})
}
for (const variant of newEditorVariants) {
it(`ignores ?editor-redesign-new-users=${variant} when disabled by user`, function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'editor-redesign-new-users': variant,
})
const scope = mockScope()
const { result } = renderHook(() => useNewEditorVariant(), {
wrapper: ({ children }) => (
<EditorProviders
scope={scope}
userSettings={{ enableNewEditor: false }}
>
{children}
</EditorProviders>
),
})
expect(result.current).to.equal('default')
})
}
it(`handles ?editor-redesign=enabled`, function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'editor-redesign': 'enabled',
})
const scope = mockScope()
const { result } = renderHook(() => useNewEditorVariant(), {
wrapper: ({ children }) => (
<EditorProviders
scope={scope}
userSettings={{ enableNewEditor: true }}
>
{children}
</EditorProviders>
),
})
expect(result.current).to.equal('new-editor')
})
})
})
@@ -72,7 +72,7 @@ const defaultUserSettings = {
}
export type EditorProvidersProps = {
user?: { id: string; email: string }
user?: { id: string; email: string; signUpDate?: string }
projectId?: string
projectName?: string
projectOwner?: ProjectMetadata['owner']
@@ -147,7 +147,11 @@ const layoutContextDefault = {
} satisfies Partial<LayoutContextValue>
export function EditorProviders({
user = { id: USER_ID, email: USER_EMAIL },
user = {
id: USER_ID,
email: USER_EMAIL,
signUpDate: '2025-10-10T10:10:10Z',
},
projectId = projectDefaults._id,
projectName = projectDefaults.name,
projectOwner = projectDefaults.owner,
@@ -223,6 +223,7 @@ describe('CompileController', function () {
url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
type: 'zip',
},
clsiCachePromptVariant: 'default',
pdfDownloadDomain: 'https://compiles.overleaf.test',
})
)
@@ -247,6 +248,7 @@ describe('CompileController', function () {
timings: undefined,
outputUrlPrefix: '/zone/b',
buildId: ctx.build_id,
clsiCachePromptVariant: 'default',
})
await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
})
@@ -268,6 +270,7 @@ describe('CompileController', function () {
url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
type: 'zip',
},
clsiCachePromptVariant: 'default',
outputUrlPrefix: '/zone/b',
pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
})
@@ -318,6 +321,7 @@ describe('CompileController', function () {
url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
type: 'zip',
},
clsiCachePromptVariant: 'default',
})
)
})
+5 -2
View File
@@ -1,3 +1,5 @@
import { ClsiCachePromptVariant } from '@/features/pdf-preview/util/types'
export type Chunk = {
start: number
end: number
@@ -69,9 +71,10 @@ export type CompileResponseData = {
pdfDownloadDomain?: string
pdfCachingMinChunkSize: number
validationProblems: any
stats: any
timings: any
stats?: Record<string, number>
timings?: Record<string, number>
outputFilesArchive?: CompileOutputFile
clsiCachePromptVariant?: ClsiCachePromptVariant
// assigned on response body by DocumentCompiler in frontend
rootDocId?: string | null