diff --git a/services/web/app/src/Features/Compile/CompileController.mjs b/services/web/app/src/Features/Compile/CompileController.mjs index 0251196a16..8af0779cfb 100644 --- a/services/web/app/src/Features/Compile/CompileController.mjs +++ b/services/web/app/src/Features/Compile/CompileController.mjs @@ -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, diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 8e55c94107..2643b1d101 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -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, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 94b3a8eafb..f80681c162 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx b/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx index 4c5c8fd731..c8953c5c5b 100644 --- a/services/web/frontend/js/features/ide-redesign/components/error-logs/error-logs.tsx +++ b/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 { 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({ ))}
+ {stoppedOnFirstError && includeErrors && } diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state/rendering-error-expected-state.tsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state/rendering-error-expected-state.tsx index ba610f37d6..dca3065a68 100644 --- a/services/web/frontend/js/features/ide-redesign/components/pdf-preview/pdf-error-state/rendering-error-expected-state.tsx +++ b/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 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')} , ]} + extraContent={} /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts index 50eda5c5a4..b94bb080df 100644 --- a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts +++ b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts @@ -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' +} diff --git a/services/web/frontend/js/features/pdf-preview/components/clsi-cache-prompt.tsx b/services/web/frontend/js/features/pdf-preview/components/clsi-cache-prompt.tsx new file mode 100644 index 0000000000..05e8c971f2 --- /dev/null +++ b/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 ( + setSayThanks(false)} + content={t('clsi_cache_prompt_thanks')} + /> + ) + case dismiss || hasRatedProject: + return null + case clsiCachePromptSegmentation?.[clsiCachePromptVariant] != null: { + let question: string + let answers: Record + 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 ( + } + isDismissible + onDismiss={dismissFeedback} + content={ + <> + {question} +
+ {Object.entries(answers).map(([feedback, text]) => ( + submitFeedback(feedback)} + key={feedback} + > + {text} + + ))} + + } + /> + ) + } + default: + return null + } +} + +function ClsiCachePrompt() { + const { clsiCachePromptVariant, showLogs } = useCompileContext() + + const onlyInLogsPane = clsiCachePromptVariant === 'preview-error' + if (clsiCachePromptVariant === 'default' || showLogs !== onlyInLogsPane) { + return null + } + return +} + +export default memo(ClsiCachePrompt) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx index b0fbb4cf93..ffac8b8d5d 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx @@ -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(() => { diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.tsx index 65a8a2e11c..aec55ff401 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.tsx +++ b/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 { 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" >
+ {clsiCachePromptVariant === 'preview-error' && } + {codeCheckFailed && } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx index 90f368960b..dccd7748e0 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx +++ b/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 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() { }>
+
{newErrorLogsPosition ? ( diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts index e6c24c4105..1ac85ede57 100644 --- a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts @@ -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, ] ) diff --git a/services/web/frontend/js/features/pdf-preview/util/compiler.ts b/services/web/frontend/js/features/pdf-preview/util/compiler.ts index 573b5268b2..aa4ec325d2 100644 --- a/services/web/frontend/js/features/pdf-preview/util/compiler.ts +++ b/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) + if (data.timings) data.timings.compileTimeClientE2E = compileTimeClientE2E const { deliveryLatencies, firstRenderDone } = trackPdfDownload( data, compileTimeClientE2E, diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts index b0d069aeb7..0da96de6aa 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.ts @@ -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')) diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts index 5c73bee2b9..3473b2f7db 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts @@ -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, diff --git a/services/web/frontend/js/features/pdf-preview/util/types.ts b/services/web/frontend/js/features/pdf-preview/util/types.ts index 6cae7a07a0..5592e42cbe 100644 --- a/services/web/frontend/js/features/pdf-preview/util/types.ts +++ b/services/web/frontend/js/features/pdf-preview/util/types.ts @@ -42,6 +42,17 @@ export type PdfFileDataList = { archive?: PdfFileArchiveData } +export type ClsiCachePromptVariant = + | 'default' + | 'compile' + | 'preview' + | 'preview-error' + | 'synctex' +export type ClsiCachePromptSegmentation = Record< + ClsiCachePromptVariant, + Record | null +> + export type HighlightData = { page: number h: number diff --git a/services/web/frontend/js/shared/context/detach-compile-context.tsx b/services/web/frontend/js/shared/context/detach-compile-context.tsx index 6cb0ca3170..dcdef27507 100644 --- a/services/web/frontend/js/shared/context/detach-compile-context.tsx +++ b/services/web/frontend/js/shared/context/detach-compile-context.tsx @@ -56,6 +56,9 @@ export const DetachCompileProvider: FC = ({ 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 = ({ '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 = ({ setStopOnValidationError, showLogs, showCompileTimeWarning, + clsiCachePromptVariant, + clsiCachePromptSegmentation, + setClsiCachePromptSegmentation, startCompile, stopCompile, stopOnFirstError, @@ -472,6 +496,9 @@ export const DetachCompileProvider: FC = ({ setStopOnValidationError, showCompileTimeWarning, showLogs, + clsiCachePromptVariant, + clsiCachePromptSegmentation, + setClsiCachePromptSegmentation, startCompile, stopCompile, stopOnFirstError, diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index 25e292e0bf..e2d5eb65bb 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/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 { 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 + > stopOnFirstError: boolean stopOnValidationError: boolean stoppedOnFirstError: boolean @@ -200,7 +207,8 @@ export const LocalCompileProvider: FC = ({ // 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 = ({ useState(false) // Raw data from clsi-cache, will need post-processing and check settings const [dataFromCache, setDataFromCache] = useState() + const [compileFromCacheStartedAt, setCompileFromCacheStartedAt] = useState(0) // whether the cache is being cleared const [clearingCache, setClearingCache] = useState(false) @@ -216,6 +225,18 @@ export const LocalCompileProvider: FC = ({ // whether the logs should be visible const [showLogs, setShowLogs] = useState(false) + // flags for clsi-cache prompt + const [clsiCachePromptVariant, setClsiCachePromptVariant] = + useState('default') + const [clsiCachePromptSegmentation, setClsiCachePromptSegmentation] = + useState({ + 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 = ({ 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 = ({ 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 = ({ setPendingInitialCompileFromCache(false) }, [ projectId, + compileFromCacheStartedAt, dataFromCache, joinedOnce, currentDocument, @@ -486,6 +517,32 @@ export const LocalCompileProvider: FC = ({ 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 = ({ setStopOnFirstError, setStopOnValidationError, showLogs, + clsiCachePromptVariant, + clsiCachePromptSegmentation, + setClsiCachePromptSegmentation, startCompile, stopCompile, stopOnFirstError, @@ -831,6 +891,9 @@ export const LocalCompileProvider: FC = ({ setStopOnValidationError, showCompileTimeWarning, showLogs, + clsiCachePromptVariant, + clsiCachePromptSegmentation, + setClsiCachePromptSegmentation, startCompile, stopCompile, stopOnFirstError, diff --git a/services/web/frontend/stylesheets/pages/editor/clsi-cache-prompt.scss b/services/web/frontend/stylesheets/pages/editor/clsi-cache-prompt.scss new file mode 100644 index 0000000000..910ec6aa0a --- /dev/null +++ b/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; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9be1af9f9e..ad0d77b055 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -355,6 +355,19 @@ "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete 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)", diff --git a/services/web/test/frontend/components/pdf-preview/new-editor-utils.test.tsx b/services/web/test/frontend/components/pdf-preview/new-editor-utils.test.tsx new file mode 100644 index 0000000000..f053e497cf --- /dev/null +++ b/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 }) => ( + + {children} + + ), + }) + 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 }) => ( + + {children} + + ), + }) + 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 }) => ( + + {children} + + ), + }) + expect(result.current).to.equal('new-editor') + }) + }) +}) diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index 5387feee16..764b0cb43d 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -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 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, diff --git a/services/web/test/unit/src/Compile/CompileController.test.mjs b/services/web/test/unit/src/Compile/CompileController.test.mjs index e8203be753..fda3f211fd 100644 --- a/services/web/test/unit/src/Compile/CompileController.test.mjs +++ b/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`, 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', }) ) }) diff --git a/services/web/types/compile.ts b/services/web/types/compile.ts index f666b850c7..31920c7c21 100644 --- a/services/web/types/compile.ts +++ b/services/web/types/compile.ts @@ -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 + timings?: Record outputFilesArchive?: CompileOutputFile + clsiCachePromptVariant?: ClsiCachePromptVariant // assigned on response body by DocumentCompiler in frontend rootDocId?: string | null