Просмотр исходного кода

[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
Jakob Ackermann 11 месяцев назад
Родитель
Сommit
f3f6a8a8e0
23 измененных файлов с 576 добавлено и 19 удалено
  1. 17 1
      services/web/app/src/Features/Compile/CompileController.mjs
  2. 15 2
      services/web/app/src/Features/Project/ProjectController.mjs
  3. 13 0
      services/web/frontend/extracted-translations.json
  4. 2 0
      services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx
  5. 2 0
      services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state/rendering-error-expected-state.tsx
  6. 10 0
      services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts
  7. 146 0
      services/web/frontend/js/features/pdf-preview/components/clsi-cache-prompt.tsx
  8. 68 5
      services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
  9. 4 0
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.tsx
  10. 2 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx
  11. 53 3
      services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts
  12. 1 0
      services/web/frontend/js/features/pdf-preview/util/compiler.ts
  13. 3 1
      services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts
  14. 4 0
      services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts
  15. 11 0
      services/web/frontend/js/features/pdf-preview/util/types.ts
  16. 27 0
      services/web/frontend/js/shared/context/detach-compile-context.tsx
  17. 66 3
      services/web/frontend/js/shared/context/local-compile-context.tsx
  18. 27 0
      services/web/frontend/stylesheets/pages/editor/clsi-cache-prompt.scss
  19. 13 0
      services/web/locales/en.json
  20. 77 0
      services/web/test/frontend/components/pdf-preview/new-editor-utils.test.tsx
  21. 6 2
      services/web/test/frontend/helpers/editor-providers.tsx
  22. 4 0
      services/web/test/unit/src/Compile/CompileController.test.mjs
  23. 5 2
      services/web/types/compile.ts

+ 17 - 1
services/web/app/src/Features/Compile/CompileController.mjs

@@ -74,8 +74,9 @@ async function _getSplitTestOptions(req, res) {
       'populate-clsi-cache'
       'populate-clsi-cache'
     )
     )
   let populateClsiCache = populateClsiCacheVariant === 'enabled'
   let populateClsiCache = populateClsiCacheVariant === 'enabled'
-  const compileFromClsiCache = populateClsiCache // use same split-test
+  let compileFromClsiCache = populateClsiCache // use same split-test
 
 
+  let clsiCachePromptVariant = 'default'
   if (!populateClsiCache) {
   if (!populateClsiCache) {
     // Pre-populate the cache for the users in the split-test for prompts.
     // Pre-populate the cache for the users in the split-test for prompts.
     // Keep the compile from cache disabled for now.
     // Keep the compile from cache disabled for now.
@@ -84,7 +85,17 @@ async function _getSplitTestOptions(req, res) {
       res,
       res,
       'populate-clsi-cache-for-prompt'
       'populate-clsi-cache-for-prompt'
     )
     )
+    ;({ variant: clsiCachePromptVariant } =
+      await SplitTestHandler.promises.getAssignment(
+        editorReq,
+        res,
+        'clsi-cache-prompt'
+      ))
     populateClsiCache = variant === 'enabled'
     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
   const pdfDownloadDomain = Settings.pdfDownloadDomain
@@ -94,6 +105,7 @@ async function _getSplitTestOptions(req, res) {
     return {
     return {
       compileFromClsiCache,
       compileFromClsiCache,
       populateClsiCache,
       populateClsiCache,
+      clsiCachePromptVariant,
       pdfDownloadDomain,
       pdfDownloadDomain,
       enablePdfCaching: false,
       enablePdfCaching: false,
     }
     }
@@ -113,6 +125,7 @@ async function _getSplitTestOptions(req, res) {
     return {
     return {
       compileFromClsiCache,
       compileFromClsiCache,
       populateClsiCache,
       populateClsiCache,
+      clsiCachePromptVariant,
       pdfDownloadDomain,
       pdfDownloadDomain,
       enablePdfCaching: false,
       enablePdfCaching: false,
     }
     }
@@ -121,6 +134,7 @@ async function _getSplitTestOptions(req, res) {
   return {
   return {
     compileFromClsiCache,
     compileFromClsiCache,
     populateClsiCache,
     populateClsiCache,
+    clsiCachePromptVariant,
     pdfDownloadDomain,
     pdfDownloadDomain,
     enablePdfCaching,
     enablePdfCaching,
     pdfCachingMinChunkSize,
     pdfCachingMinChunkSize,
@@ -212,6 +226,7 @@ const _CompileController = {
     let {
     let {
       compileFromClsiCache,
       compileFromClsiCache,
       populateClsiCache,
       populateClsiCache,
+      clsiCachePromptVariant,
       enablePdfCaching,
       enablePdfCaching,
       pdfCachingMinChunkSize,
       pdfCachingMinChunkSize,
       pdfDownloadDomain,
       pdfDownloadDomain,
@@ -294,6 +309,7 @@ const _CompileController = {
       compileGroup: limits?.compileGroup,
       compileGroup: limits?.compileGroup,
       clsiServerId,
       clsiServerId,
       clsiCacheShard,
       clsiCacheShard,
+      clsiCachePromptVariant,
       validationProblems,
       validationProblems,
       stats,
       stats,
       timings,
       timings,

+ 15 - 2
services/web/app/src/Features/Project/ProjectController.mjs

@@ -386,6 +386,7 @@ const _ProjectController = {
       'external-socket-heartbeat',
       'external-socket-heartbeat',
       'null-test-share-modal',
       'null-test-share-modal',
       'populate-clsi-cache',
       'populate-clsi-cache',
+      'populate-clsi-cache-for-prompt',
       'pdf-caching-cached-url-lookup',
       'pdf-caching-cached-url-lookup',
       'pdf-caching-mode',
       'pdf-caching-mode',
       'pdf-caching-prefetch-large',
       'pdf-caching-prefetch-large',
@@ -818,14 +819,26 @@ const _ProjectController = {
 
 
       const planDetails = Settings.plans.find(p => p.planCode === planCode)
       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, {
       res.render(template, {
         title: project.name,
         title: project.name,
         priority_title: true,
         priority_title: true,
         bodyClasses: ['editor'],
         bodyClasses: ['editor'],
         project_id: project._id,
         project_id: project._id,
         projectName: project.name,
         projectName: project.name,
-        projectOwnerHasPremiumOnPageLoad:
-          ownerFeatures?.compileGroup === 'priority',
+        projectOwnerHasPremiumOnPageLoad,
         user: {
         user: {
           id: userId,
           id: userId,
           email: user.email,
           email: user.email,

+ 13 - 0
services/web/frontend/extracted-translations.json

@@ -278,6 +278,19 @@
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clone_with_git": "",
   "clone_with_git": "",
   "close": "",
   "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_maintenance": "",
   "clsi_unavailable": "",
   "clsi_unavailable": "",
   "code_check_failed": "",
   "code_check_failed": "",

+ 2 - 0
services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx

@@ -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 PdfDownloadFilesButton from '@/features/pdf-preview/components/pdf-download-files-button'
 import { useIsNewErrorLogsPositionEnabled } from '../../utils/new-editor-utils'
 import { useIsNewErrorLogsPositionEnabled } from '../../utils/new-editor-utils'
 import RollingBuildSelectedReminder from './rolling-build-selected-reminder'
 import RollingBuildSelectedReminder from './rolling-build-selected-reminder'
+import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
 
 
 const logsComponents: Array<{
 const logsComponents: Array<{
   import: { default: ElementType }
   import: { default: ElementType }
@@ -86,6 +87,7 @@ function ErrorLogs({
       ))}
       ))}
       <TabContent className="error-logs new-error-logs">
       <TabContent className="error-logs new-error-logs">
         <div className="logs-pane-content">
         <div className="logs-pane-content">
+          <ClsiCachePrompt />
           <RollingBuildSelectedReminder />
           <RollingBuildSelectedReminder />
           {stoppedOnFirstError && includeErrors && <StopOnFirstErrorPrompt />}
           {stoppedOnFirstError && includeErrors && <StopOnFirstErrorPrompt />}
 
 

+ 2 - 0
services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state/rendering-error-expected-state.tsx

@@ -2,6 +2,7 @@ import { Trans, useTranslation } from 'react-i18next'
 import ErrorState from './error-state'
 import ErrorState from './error-state'
 import OLButton from '@/shared/components/ol/ol-button'
 import OLButton from '@/shared/components/ol/ol-button'
 import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
 import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
+import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
 
 
 export default function RenderingErrorExpectedState() {
 export default function RenderingErrorExpectedState() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -30,6 +31,7 @@ export default function RenderingErrorExpectedState() {
           {t('recompile')}
           {t('recompile')}
         </OLButton>,
         </OLButton>,
       ]}
       ]}
+      extraContent={<ClsiCachePrompt />}
     />
     />
   )
   )
 }
 }

+ 10 - 0
services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts

@@ -86,3 +86,13 @@ export const useAreNewErrorLogsEnabled = () => {
   const newEditorEnabled = useIsNewEditorEnabled()
   const newEditorEnabled = useIsNewEditorEnabled()
   return newEditorEnabled && canUseNewLogs()
   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'
+}

+ 146 - 0
services/web/frontend/js/features/pdf-preview/components/clsi-cache-prompt.tsx

@@ -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)

+ 68 - 5
services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx

@@ -25,8 +25,14 @@ type PdfJsViewerProps = {
 function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
 function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
   const { projectId } = useProjectContext()
   const { projectId } = useProjectContext()
 
 
-  const { setError, firstRenderDone, highlights, position, setPosition } =
-    useCompileContext()
+  const {
+    setError,
+    firstRenderDone,
+    highlights,
+    position,
+    setPosition,
+    setClsiCachePromptSegmentation,
+  } = useCompileContext()
 
 
   const { setLoadingError } = usePdfPreviewContext()
   const { setLoadingError } = usePdfPreviewContext()
 
 
@@ -82,6 +88,10 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
   }, [])
   }, [])
 
 
   const [startFetch, setStartFetch] = useState(0)
   const [startFetch, setStartFetch] = useState(0)
+  const startFetchRef = useRef(startFetch)
+  useEffect(() => {
+    startFetchRef.current = startFetch
+  }, [startFetch])
 
 
   // listen for events and trigger rendering.
   // listen for events and trigger rendering.
   // Do everything in one effect to mitigate de-sync between events.
   // Do everything in one effect to mitigate de-sync between events.
@@ -148,6 +158,27 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
       setPage(event.pageNumber)
       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.
     // `pagesinit` fires when the data for rendering the first page is ready.
     pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
     pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
     // `pagerendered` fires when a page was actually rendered.
     // `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('pagerendered', handleRenderedInitialPageNumber)
       pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged)
       pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged)
       pdfJsWrapper.eventBus.off('pagechanging', handlePageChanging)
       pdfJsWrapper.eventBus.off('pagechanging', handlePageChanging)
+      pdfJsWrapper.eventBus.off('pagerendered', handleRenderedClsiCachePrompt)
     }
     }
-  }, [pdfJsWrapper, firstRenderDone, startFetch])
+  }, [
+    pdfJsWrapper,
+    firstRenderDone,
+    startFetch,
+    setClsiCachePromptSegmentation,
+  ])
 
 
   // load the PDF document from the URL
   // load the PDF document from the URL
   useEffect(() => {
   useEffect(() => {
     if (pdfJsWrapper && url) {
     if (pdfJsWrapper && url) {
       setInitialised(false)
       setInitialised(false)
       setError(undefined)
       setError(undefined)
-      setStartFetch(performance.now())
+      const t0 = performance.now()
+      setStartFetch(t0)
 
 
       const abortController = new AbortController()
       const abortController = new AbortController()
       const handleFetchError = (err: any) => {
       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.
         // The error is already logged at the call-site with additional context.
         if (err instanceof PDFJS.ResponseException && err.missing) {
         if (err instanceof PDFJS.ResponseException && err.missing) {
           setError('rendering-error-expected')
           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 {
         } else {
           setError('rendering-error')
           setError('rendering-error')
         }
         }
@@ -190,6 +243,9 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
           if (doc) {
           if (doc) {
             setTotalPages(doc.numPages)
             setTotalPages(doc.numPages)
           }
           }
+          setClsiCachePromptSegmentation(prev => {
+            return { ...prev, preview: null, 'preview-error': null }
+          })
         })
         })
         .catch(error => {
         .catch(error => {
           if (abortController.signal.aborted) return
           if (abortController.signal.aborted) return
@@ -200,7 +256,14 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
         abortController.abort()
         abortController.abort()
       }
       }
     }
     }
-  }, [pdfJsWrapper, url, pdfFile, setError, setStartFetch])
+  }, [
+    pdfJsWrapper,
+    url,
+    pdfFile,
+    setError,
+    setStartFetch,
+    setClsiCachePromptSegmentation,
+  ])
 
 
   // listen for scroll events
   // listen for scroll events
   useEffect(() => {
   useEffect(() => {

+ 4 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.tsx

@@ -16,6 +16,7 @@ import { useDetachCompileContext as useCompileContext } from '../../../shared/co
 import PdfLogEntry from './pdf-log-entry'
 import PdfLogEntry from './pdf-log-entry'
 import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
 import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
+import ClsiCachePrompt from '@/features/pdf-preview/components/clsi-cache-prompt'
 
 
 function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
 function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
   const {
   const {
@@ -26,6 +27,7 @@ function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
     validationIssues,
     validationIssues,
     showLogs,
     showLogs,
     stoppedOnFirstError,
     stoppedOnFirstError,
+    clsiCachePromptVariant,
   } = useCompileContext()
   } = useCompileContext()
 
 
   const { loadingError } = usePdfPreviewContext()
   const { loadingError } = usePdfPreviewContext()
@@ -42,6 +44,8 @@ function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
       data-testid="logs-pane"
       data-testid="logs-pane"
     >
     >
       <div className="logs-pane-content">
       <div className="logs-pane-content">
+        {clsiCachePromptVariant === 'preview-error' && <ClsiCachePrompt />}
+
         <RollingBuildSelectedReminder />
         <RollingBuildSelectedReminder />
 
 
         {codeCheckFailed && <PdfCodeCheckFailedNotice />}
         {codeCheckFailed && <PdfCodeCheckFailedNotice />}

+ 2 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx

@@ -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 PdfCodeCheckFailedBanner from '@/features/ide-redesign/components/pdf-preview/pdf-code-check-failed-banner'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
 import NewPdfLogsViewer from '@/features/ide-redesign/components/pdf-preview/pdf-logs-viewer'
 import NewPdfLogsViewer from '@/features/ide-redesign/components/pdf-preview/pdf-logs-viewer'
+import ClsiCachePrompt from './clsi-cache-prompt'
 
 
 function PdfPreviewPane() {
 function PdfPreviewPane() {
   const { pdfUrl } = useCompileContext()
   const { pdfUrl } = useCompileContext()
@@ -50,6 +51,7 @@ function PdfPreviewPane() {
         <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
         <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
           <div className="pdf-viewer" data-testid="pdf-viewer">
           <div className="pdf-viewer" data-testid="pdf-viewer">
             <PdfViewer />
             <PdfViewer />
+            <ClsiCachePrompt />
           </div>
           </div>
         </Suspense>
         </Suspense>
         {newErrorLogsPosition ? (
         {newErrorLogsPosition ? (

+ 53 - 3
services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts

@@ -1,6 +1,6 @@
 import { useCallback, useEffect, useState, useRef } from 'react'
 import { useCallback, useEffect, useState, useRef } from 'react'
 import { useProjectContext } from '../../../shared/context/project-context'
 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 { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
 import useIsMounted from '../../../shared/hooks/use-is-mounted'
 import useIsMounted from '../../../shared/hooks/use-is-mounted'
 import useAbortController from '../../../shared/hooks/use-abort-controller'
 import useAbortController from '../../../shared/hooks/use-abort-controller'
@@ -33,8 +33,14 @@ export default function useSynctex(): {
   const { projectId, project } = useProjectContext()
   const { projectId, project } = useProjectContext()
   const rootDocId = project?.rootDocId
   const rootDocId = project?.rootDocId
 
 
-  const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
-    useCompileContext()
+  const {
+    clsiServerId,
+    pdfFile,
+    position,
+    setShowLogs,
+    setHighlights,
+    setClsiCachePromptSegmentation,
+  } = useCompileContext()
 
 
   const { selectedEntities } = useFileTreeData()
   const { selectedEntities } = useFileTreeData()
   const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
   const { findEntityByPath, dirname, pathInFolder } = useFileTreePathContext()
@@ -121,6 +127,16 @@ export default function useSynctex(): {
         .then(data => {
         .then(data => {
           setShowLogs(false)
           setShowLogs(false)
           setHighlights(data.pdf)
           setHighlights(data.pdf)
+          setClsiCachePromptSegmentation(prev => {
+            return {
+              ...prev,
+              synctex: {
+                direction: 'pdf',
+                navigationFailed: false,
+                restoredFromCache: data.downloadedFromCache,
+              },
+            }
+          })
           if (data.downloadedFromCache) {
           if (data.downloadedFromCache) {
             sendMB('synctex-downloaded-from-cache', {
             sendMB('synctex-downloaded-from-cache', {
               projectId,
               projectId,
@@ -129,6 +145,17 @@ export default function useSynctex(): {
           }
           }
         })
         })
         .catch(error => {
         .catch(error => {
+          if (error instanceof FetchError && error.response?.status === 404) {
+            setClsiCachePromptSegmentation(prev => {
+              return {
+                ...prev,
+                synctex: {
+                  direction: 'pdf',
+                  navigationFailed: true,
+                },
+              }
+            })
+          }
           showSynctexRequestErrorToast()
           showSynctexRequestErrorToast()
           debugConsole.error(error)
           debugConsole.error(error)
         })
         })
@@ -146,6 +173,7 @@ export default function useSynctex(): {
       setShowLogs,
       setShowLogs,
       setHighlights,
       setHighlights,
       setSyncToPdfInFlight,
       setSyncToPdfInFlight,
+      setClsiCachePromptSegmentation,
       signal,
       signal,
     ]
     ]
   )
   )
@@ -235,6 +263,16 @@ export default function useSynctex(): {
         .then(data => {
         .then(data => {
           const [{ file, line }] = data.code
           const [{ file, line }] = data.code
           goToCodeLine(file, line, selectText)
           goToCodeLine(file, line, selectText)
+          setClsiCachePromptSegmentation(prev => {
+            return {
+              ...prev,
+              synctex: {
+                direction: 'code',
+                navigationFailed: false,
+                restoredFromCache: data.downloadedFromCache,
+              },
+            }
+          })
           if (data.downloadedFromCache) {
           if (data.downloadedFromCache) {
             sendMB('synctex-downloaded-from-cache', {
             sendMB('synctex-downloaded-from-cache', {
               projectId,
               projectId,
@@ -243,6 +281,17 @@ export default function useSynctex(): {
           }
           }
         })
         })
         .catch(error => {
         .catch(error => {
+          if (error instanceof FetchError && error.response?.status === 404) {
+            setClsiCachePromptSegmentation(prev => {
+              return {
+                ...prev,
+                synctex: {
+                  direction: 'code',
+                  navigationFailed: true,
+                },
+              }
+            })
+          }
           debugConsole.error(error)
           debugConsole.error(error)
           showSynctexRequestErrorToast()
           showSynctexRequestErrorToast()
         })
         })
@@ -259,6 +308,7 @@ export default function useSynctex(): {
       signal,
       signal,
       isMounted,
       isMounted,
       setSyncToCodeInFlight,
       setSyncToCodeInFlight,
+      setClsiCachePromptSegmentation,
       goToCodeLine,
       goToCodeLine,
     ]
     ]
   )
   )

+ 1 - 0
services/web/frontend/js/features/pdf-preview/util/compiler.ts

@@ -153,6 +153,7 @@ export default class DocumentCompiler {
       )
       )
 
 
       const compileTimeClientE2E = Math.ceil(performance.now() - t0)
       const compileTimeClientE2E = Math.ceil(performance.now() - t0)
+      if (data.timings) data.timings.compileTimeClientE2E = compileTimeClientE2E
       const { deliveryLatencies, firstRenderDone } = trackPdfDownload(
       const { deliveryLatencies, firstRenderDone } = trackPdfDownload(
         data,
         data,
         compileTimeClientE2E,
         compileTimeClientE2E,

+ 3 - 1
services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts

@@ -33,4 +33,6 @@ export const projectOwnerHasPremiumOnPageLoad = getMeta(
   'ol-projectOwnerHasPremiumOnPageLoad'
   'ol-projectOwnerHasPremiumOnPageLoad'
 )
 )
 export const fallBackToClsiCache =
 export const fallBackToClsiCache =
-  projectOwnerHasPremiumOnPageLoad && isFlagEnabled('populate-clsi-cache')
+  projectOwnerHasPremiumOnPageLoad &&
+  (isFlagEnabled('populate-clsi-cache') ||
+    isFlagEnabled('populate-clsi-cache-for-prompt'))

+ 4 - 0
services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts

@@ -48,6 +48,10 @@ export default class PDFJSWrapper {
     this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
     this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
   }
   }
 
 
+  usedClsiCache() {
+    return !!this.pdfCachingTransportFactory?.sentEventFallbackToClsiCache
+  }
+
   // load a document from a URL
   // load a document from a URL
   async loadDocument({
   async loadDocument({
     url,
     url,

+ 11 - 0
services/web/frontend/js/features/pdf-preview/util/types.ts

@@ -42,6 +42,17 @@ export type PdfFileDataList = {
   archive?: PdfFileArchiveData
   archive?: PdfFileArchiveData
 }
 }
 
 
+export type ClsiCachePromptVariant =
+  | 'default'
+  | 'compile'
+  | 'preview'
+  | 'preview-error'
+  | 'synctex'
+export type ClsiCachePromptSegmentation = Record<
+  ClsiCachePromptVariant,
+  Record<string, any> | null
+>
+
 export type HighlightData = {
 export type HighlightData = {
   page: number
   page: number
   h: number
   h: number

+ 27 - 0
services/web/frontend/js/shared/context/detach-compile-context.tsx

@@ -56,6 +56,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
     setStopOnValidationError: _setStopOnValidationError,
     setStopOnValidationError: _setStopOnValidationError,
     showLogs: _showLogs,
     showLogs: _showLogs,
     showCompileTimeWarning: _showCompileTimeWarning,
     showCompileTimeWarning: _showCompileTimeWarning,
+    clsiCachePromptVariant: _clsiCachePromptVariant,
+    clsiCachePromptSegmentation: _clsiCachePromptSegmentation,
+    setClsiCachePromptSegmentation: _setClsiCachePromptSegmentation,
     stopOnFirstError: _stopOnFirstError,
     stopOnFirstError: _stopOnFirstError,
     stopOnValidationError: _stopOnValidationError,
     stopOnValidationError: _stopOnValidationError,
     stoppedOnFirstError: _stoppedOnFirstError,
     stoppedOnFirstError: _stoppedOnFirstError,
@@ -201,6 +204,24 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
     'detacher',
     'detacher',
     'detached'
     '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(
   const [stopOnFirstError] = useDetachStateWatcher(
     'stopOnFirstError',
     'stopOnFirstError',
     _stopOnFirstError,
     _stopOnFirstError,
@@ -419,6 +440,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
       setStopOnValidationError,
       setStopOnValidationError,
       showLogs,
       showLogs,
       showCompileTimeWarning,
       showCompileTimeWarning,
+      clsiCachePromptVariant,
+      clsiCachePromptSegmentation,
+      setClsiCachePromptSegmentation,
       startCompile,
       startCompile,
       stopCompile,
       stopCompile,
       stopOnFirstError,
       stopOnFirstError,
@@ -472,6 +496,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
       setStopOnValidationError,
       setStopOnValidationError,
       showCompileTimeWarning,
       showCompileTimeWarning,
       showLogs,
       showLogs,
+      clsiCachePromptVariant,
+      clsiCachePromptSegmentation,
+      setClsiCachePromptSegmentation,
       startCompile,
       startCompile,
       stopCompile,
       stopCompile,
       stopOnFirstError,
       stopOnFirstError,

+ 66 - 3
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -39,18 +39,20 @@ import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { getJSON } from '@/infrastructure/fetch-json'
 import { getJSON } from '@/infrastructure/fetch-json'
-import { CompileResponseData } from '../../../../types/compile'
+import { CompileResponseData, PDFFile } from '../../../../types/compile'
 import {
 import {
   PdfScrollPosition,
   PdfScrollPosition,
   usePdfScrollPosition,
   usePdfScrollPosition,
 } from '@/shared/hooks/use-pdf-scroll-position'
 } from '@/shared/hooks/use-pdf-scroll-position'
 import {
 import {
+  ClsiCachePromptSegmentation,
+  ClsiCachePromptVariant,
   DeliveryLatencies,
   DeliveryLatencies,
   HighlightData,
   HighlightData,
   LogEntry,
   LogEntry,
   PdfFileDataList,
   PdfFileDataList,
 } from '@/features/pdf-preview/util/types'
 } from '@/features/pdf-preview/util/types'
-import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { getSplitTestVariant, isSplitTestEnabled } from '@/utils/splitTestUtils'
 import { captureException } from '@/infrastructure/error-reporter'
 import { captureException } from '@/infrastructure/error-reporter'
 import OError from '@overleaf/o-error'
 import OError from '@overleaf/o-error'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
@@ -99,6 +101,11 @@ export type CompileContext = {
   setStopOnValidationError: (value: boolean) => void
   setStopOnValidationError: (value: boolean) => void
   showCompileTimeWarning: boolean
   showCompileTimeWarning: boolean
   showLogs: boolean
   showLogs: boolean
+  clsiCachePromptVariant: ClsiCachePromptVariant
+  clsiCachePromptSegmentation: ClsiCachePromptSegmentation
+  setClsiCachePromptSegmentation: Dispatch<
+    SetStateAction<ClsiCachePromptSegmentation>
+  >
   stopOnFirstError: boolean
   stopOnFirstError: boolean
   stopOnValidationError: boolean
   stopOnValidationError: boolean
   stoppedOnFirstError: boolean
   stoppedOnFirstError: boolean
@@ -200,7 +207,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   // fetch initial compile response from cache
   // fetch initial compile response from cache
   const [initialCompileFromCache, setInitialCompileFromCache] = useState(
   const [initialCompileFromCache, setInitialCompileFromCache] = useState(
     getMeta('ol-projectOwnerHasPremiumOnPageLoad') &&
     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
       // Avoid fetching the initial compile from cache in PDF detach tab
       role !== 'detached'
       role !== 'detached'
   )
   )
@@ -209,6 +217,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
     useState(false)
     useState(false)
   // Raw data from clsi-cache, will need post-processing and check settings
   // Raw data from clsi-cache, will need post-processing and check settings
   const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
   const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
+  const [compileFromCacheStartedAt, setCompileFromCacheStartedAt] = useState(0)
 
 
   // whether the cache is being cleared
   // whether the cache is being cleared
   const [clearingCache, setClearingCache] = useState(false)
   const [clearingCache, setClearingCache] = useState(false)
@@ -216,6 +225,18 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   // whether the logs should be visible
   // whether the logs should be visible
   const [showLogs, setShowLogs] = useState(false)
   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
   // whether the compile dropdown arrow should be animated
   const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
   const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
     useState(false)
     useState(false)
@@ -356,6 +377,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   useEffect(() => {
   useEffect(() => {
     if (initialCompileFromCache && !pendingInitialCompileFromCache) {
     if (initialCompileFromCache && !pendingInitialCompileFromCache) {
       setPendingInitialCompileFromCache(true)
       setPendingInitialCompileFromCache(true)
+      setCompileFromCacheStartedAt(performance.now())
       getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
       getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
         .then((data: any) => {
         .then((data: any) => {
           // Hand data over to next effect, it will wait for project/doc loading.
           // 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) {
     if (settingsUpToDate) {
       sendMB('compile-from-cache', { projectId })
       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)
       setData(dataFromCache)
       setCompiledOnce(true)
       setCompiledOnce(true)
     }
     }
@@ -409,6 +439,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
     setPendingInitialCompileFromCache(false)
     setPendingInitialCompileFromCache(false)
   }, [
   }, [
     projectId,
     projectId,
+    compileFromCacheStartedAt,
     dataFromCache,
     dataFromCache,
     joinedOnce,
     joinedOnce,
     currentDocument,
     currentDocument,
@@ -486,6 +517,32 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
       if (data.clsiServerId) {
       if (data.clsiServerId) {
         setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
         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) {
       if (data.outputFiles) {
         const outputFiles = new Map()
         const outputFiles = new Map()
@@ -781,6 +838,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
       setStopOnFirstError,
       setStopOnFirstError,
       setStopOnValidationError,
       setStopOnValidationError,
       showLogs,
       showLogs,
+      clsiCachePromptVariant,
+      clsiCachePromptSegmentation,
+      setClsiCachePromptSegmentation,
       startCompile,
       startCompile,
       stopCompile,
       stopCompile,
       stopOnFirstError,
       stopOnFirstError,
@@ -831,6 +891,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
       setStopOnValidationError,
       setStopOnValidationError,
       showCompileTimeWarning,
       showCompileTimeWarning,
       showLogs,
       showLogs,
+      clsiCachePromptVariant,
+      clsiCachePromptSegmentation,
+      setClsiCachePromptSegmentation,
       startCompile,
       startCompile,
       stopCompile,
       stopCompile,
       stopOnFirstError,
       stopOnFirstError,

+ 27 - 0
services/web/frontend/stylesheets/pages/editor/clsi-cache-prompt.scss

@@ -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 - 0
services/web/locales/en.json

@@ -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.",
   "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",
   "clone_with_git": "Clone with Git",
   "close": "Close",
   "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_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.",
   "clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",
   "cn": "Chinese (Simplified)",
   "cn": "Chinese (Simplified)",

+ 77 - 0
services/web/test/frontend/components/pdf-preview/new-editor-utils.test.tsx

@@ -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')
+    })
+  })
+})

+ 6 - 2
services/web/test/frontend/helpers/editor-providers.tsx

@@ -72,7 +72,7 @@ const defaultUserSettings = {
 }
 }
 
 
 export type EditorProvidersProps = {
 export type EditorProvidersProps = {
-  user?: { id: string; email: string }
+  user?: { id: string; email: string; signUpDate?: string }
   projectId?: string
   projectId?: string
   projectName?: string
   projectName?: string
   projectOwner?: ProjectMetadata['owner']
   projectOwner?: ProjectMetadata['owner']
@@ -147,7 +147,11 @@ const layoutContextDefault = {
 } satisfies Partial<LayoutContextValue>
 } satisfies Partial<LayoutContextValue>
 
 
 export function EditorProviders({
 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,
   projectId = projectDefaults._id,
   projectName = projectDefaults.name,
   projectName = projectDefaults.name,
   projectOwner = projectDefaults.owner,
   projectOwner = projectDefaults.owner,

+ 4 - 0
services/web/test/unit/src/Compile/CompileController.test.mjs

@@ -223,6 +223,7 @@ describe('CompileController', function () {
                 url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
                 url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
                 type: 'zip',
                 type: 'zip',
               },
               },
+              clsiCachePromptVariant: 'default',
               pdfDownloadDomain: 'https://compiles.overleaf.test',
               pdfDownloadDomain: 'https://compiles.overleaf.test',
             })
             })
           )
           )
@@ -247,6 +248,7 @@ describe('CompileController', function () {
             timings: undefined,
             timings: undefined,
             outputUrlPrefix: '/zone/b',
             outputUrlPrefix: '/zone/b',
             buildId: ctx.build_id,
             buildId: ctx.build_id,
+            clsiCachePromptVariant: 'default',
           })
           })
           await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
           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`,
                 url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
                 type: 'zip',
                 type: 'zip',
               },
               },
+              clsiCachePromptVariant: 'default',
               outputUrlPrefix: '/zone/b',
               outputUrlPrefix: '/zone/b',
               pdfDownloadDomain: 'https://compiles.overleaf.test/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`,
               url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
               type: 'zip',
               type: 'zip',
             },
             },
+            clsiCachePromptVariant: 'default',
           })
           })
         )
         )
       })
       })

+ 5 - 2
services/web/types/compile.ts

@@ -1,3 +1,5 @@
+import { ClsiCachePromptVariant } from '@/features/pdf-preview/util/types'
+
 export type Chunk = {
 export type Chunk = {
   start: number
   start: number
   end: number
   end: number
@@ -69,9 +71,10 @@ export type CompileResponseData = {
   pdfDownloadDomain?: string
   pdfDownloadDomain?: string
   pdfCachingMinChunkSize: number
   pdfCachingMinChunkSize: number
   validationProblems: any
   validationProblems: any
-  stats: any
-  timings: any
+  stats?: Record<string, number>
+  timings?: Record<string, number>
   outputFilesArchive?: CompileOutputFile
   outputFilesArchive?: CompileOutputFile
+  clsiCachePromptVariant?: ClsiCachePromptVariant
 
 
   // assigned on response body by DocumentCompiler in frontend
   // assigned on response body by DocumentCompiler in frontend
   rootDocId?: string | null
   rootDocId?: string | null