diff --git a/.gitignore b/.gitignore index 9876823717..56401fcf4e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,16 @@ terraform* # sourceror bundle /public/externalLibs/sourceror +# Conductor dev fixtures: evaluator/web-plugin bundles + local directories. +# These are built from the language repos (js-slang, py-slang) and the plugins +# monorepo, deployed to GitHub Pages, and referenced via the hosted language- +# and plugin-directories. dev.sh copies them here for local turnkey testing only; +# they must never be committed to the frontend. +/public/evaluators/js-slang/ +/public/evaluators/py-slang/ +/public/languages/ +/public/plugins/ + # misc .DS_Store .env.local diff --git a/package.json b/package.json index 2231fb1322..9ead445cd8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sourceacademy/c-slang": "^1.0.21", "@sourceacademy/conductor": "^0.5.0", "@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.10", - "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.2", + "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.3", "@sourceacademy/sharedb-ace": "2.1.1", "@sourceacademy/sling-client": "^0.1.0", "@sourceacademy/web-cse-machine": "^1.0.0", diff --git a/public/index.html b/public/index.html index eb4b84dd7c..978db11679 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,20 @@ + + diff --git a/public/shims/blueprintjs-core.mjs b/public/shims/blueprintjs-core.mjs new file mode 100644 index 0000000000..4b54d09165 --- /dev/null +++ b/public/shims/blueprintjs-core.mjs @@ -0,0 +1,20 @@ +// Import-map shim: resolves a plugin bundle's `import ... from "@blueprintjs/core"` to the host +// frontend's Blueprint build (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts), so the +// plugin's Blueprint components share the host's CSS and React instance. +// +// Re-exports the surface used by the bundled plugins; extend this list if a plugin needs more. +if (!globalThis.__SA_BLUEPRINT__) { + throw new Error('[shim] __SA_BLUEPRINT__ is not defined — conductorSharedDeps.ts may not have run yet'); +} +const Blueprint = globalThis.__SA_BLUEPRINT__; +export const { + Button, + ButtonGroup, + Card, + Classes, + Divider, + Icon, + Popover, + Pre, + Slider, +} = Blueprint; diff --git a/public/shims/react-jsx-runtime.mjs b/public/shims/react-jsx-runtime.mjs new file mode 100644 index 0000000000..b1a8864438 --- /dev/null +++ b/public/shims/react-jsx-runtime.mjs @@ -0,0 +1,7 @@ +// Import-map shim: resolves a plugin bundle's `import ... from "react/jsx-runtime"` to the host +// frontend's React jsx-runtime (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts). +if (!globalThis.__SA_REACT_JSX__) { + throw new Error('[shim] __SA_REACT_JSX__ is not defined — conductorSharedDeps.ts may not have run yet'); +} +const jsxRuntime = globalThis.__SA_REACT_JSX__; +export const { jsx, jsxs, Fragment } = jsxRuntime; diff --git a/public/shims/react.mjs b/public/shims/react.mjs new file mode 100644 index 0000000000..0422c04d53 --- /dev/null +++ b/public/shims/react.mjs @@ -0,0 +1,44 @@ +// Import-map shim: resolves a plugin bundle's `import ... from "react"` to the host frontend's +// single React instance (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts). +if (!globalThis.__SA_REACT__) { + throw new Error('[shim] __SA_REACT__ is not defined — conductorSharedDeps.ts may not have run yet'); +} +const React = globalThis.__SA_REACT__; +export default React.default ?? React; +export const { + Children, + Component, + Fragment, + Profiler, + PureComponent, + StrictMode, + Suspense, + cloneElement, + createContext, + createElement, + createRef, + forwardRef, + isValidElement, + lazy, + memo, + startTransition, + use, + useActionState, + useCallback, + useContext, + useDebugValue, + useDeferredValue, + useEffect, + useId, + useImperativeHandle, + useInsertionEffect, + useLayoutEffect, + useMemo, + useOptimistic, + useReducer, + useRef, + useState, + useSyncExternalStore, + useTransition, + version, +} = React; diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 807f313933..a11ab5fbb5 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { InjectManifest } from '@aaroon/workbox-rspack-plugin'; import { defineConfig, loadEnv } from '@rsbuild/core'; import { pluginEslint } from '@rsbuild/plugin-eslint'; @@ -27,6 +30,36 @@ export default defineConfig({ server: { port: 8000, }, + dev: { + setupMiddlewares: [ + middlewares => { + // The dev server's SPA fallback serves index.html (HTTP 200) for ANY unmatched path, + // including missing static assets. That silently turns a wrong/stale evaluator or plugin + // URL into HTML, which the Conductor then loads as a Worker script — it fails to parse + // (`Unexpected token '<'`) and the run hangs with no clear error. For these locally-served + // bundle directories, return a real 404 when the file is absent so the failure is loud and + // the Conductor surfaces a proper "cannot load evaluator" error instead of hanging. + const PUBLIC_DIR = path.join(__dirname, 'public'); + // Only guard requests for actual files (with an extension) under these locally-served + // bundle directories, so extensionless client-side routes still fall through to the SPA. + const GUARDED = /^\/(evaluators|plugins|languages)\/.+\.[^/]+$/; + middlewares.unshift((req, res, next) => { + const pathname = decodeURIComponent((req.url ?? '').split('?')[0]); + if (GUARDED.test(pathname)) { + const filePath = path.join(PUBLIC_DIR, pathname); + // Guard against path traversal, then 404 if the asset does not exist on disk. + if (!filePath.startsWith(PUBLIC_DIR) || !fs.existsSync(filePath)) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(`Not found: ${pathname}`); + return; + } + } + next(); + }); + }, + ], + }, tools: { // TODO: See if still needed rspack: config => { diff --git a/src/bootstrap/conductorSharedDeps.ts b/src/bootstrap/conductorSharedDeps.ts new file mode 100644 index 0000000000..82ee9e878a --- /dev/null +++ b/src/bootstrap/conductorSharedDeps.ts @@ -0,0 +1,20 @@ +/** + * Exposes the frontend's singleton library instances so that dynamically-imported Conductor web + * plugin bundles can share them, rather than bundling (and duplicating) their own copies. + * + * Plugin bundles import `react`, `react/jsx-runtime` and `@blueprintjs/core` as bare specifiers. + * The import map in `public/index.html` maps those specifiers to the shim modules in `public/shims`, + * which re-export the globals set here. The result: the plugin renders inside the host's single + * React tree and uses the host's exact Blueprint build (so styling is identical). + * + * This module must be imported before any plugin is loaded; it is imported first from `index.tsx`. + */ +import * as Blueprint from '@blueprintjs/core'; +// eslint-disable-next-line no-restricted-imports +import * as React from 'react'; +import * as ReactJsxRuntime from 'react/jsx-runtime'; + +const globals = globalThis as Record; +globals.__SA_REACT__ = React; +globals.__SA_REACT_JSX__ = ReactJsxRuntime; +globals.__SA_BLUEPRINT__ = Blueprint; diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index 31c617cb5f..3fab06d69b 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -72,7 +72,11 @@ import SideContentToneMatrix from '../sideContent/content/SideContentToneMatrix' import type { SideContentProps } from '../sideContent/SideContent'; import { changeSideContentHeight } from '../sideContent/SideContentActions'; import { useSideContent } from '../sideContent/SideContentHelper'; -import { type SideContentTab, SideContentType } from '../sideContent/SideContentTypes'; +import { + type SideContentTab, + type SideContentTabId, + SideContentType, +} from '../sideContent/SideContentTypes'; import Constants from '../utils/Constants'; import { useResponsive, useTypedSelector } from '../utils/Hooks'; import { assessmentTypeLink } from '../utils/ParamParseHelper'; @@ -666,8 +670,8 @@ function AssessmentWorkspace(props: AssessmentWorkspaceProps) { } const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { @@ -879,8 +883,8 @@ function AssessmentWorkspace(props: AssessmentWorkspaceProps) { const mobileSideContentProps: (q: number) => MobileSideContentProps = (questionId: number) => { const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { diff --git a/src/commons/mobileWorkspace/MobileWorkspace.tsx b/src/commons/mobileWorkspace/MobileWorkspace.tsx index fda1473735..e3124d8c3d 100644 --- a/src/commons/mobileWorkspace/MobileWorkspace.tsx +++ b/src/commons/mobileWorkspace/MobileWorkspace.tsx @@ -11,7 +11,11 @@ import McqChooser, { type McqChooserProps } from '../mcqChooser/McqChooser'; import { Prompt } from '../ReactRouterPrompt'; import type { ReplProps } from '../repl/Repl'; import type { SideBarTab } from '../sideBar/SideBar'; -import { type SideContentTab, SideContentType } from '../sideContent/SideContentTypes'; +import { + type SideContentTab, + type SideContentTabId, + SideContentType, +} from '../sideContent/SideContentTypes'; import DraggableRepl from './DraggableRepl'; import MobileKeyboard from './MobileKeyboard'; import MobileSideContent, { @@ -169,7 +173,7 @@ function MobileWorkspace(props: MobileWorkspaceProps) { const handleEditorEval = props.editorContainerProps?.handleEditorEval; const handleTabChangeForRepl = useCallback( - (newTabId: SideContentType, prevTabId: SideContentType) => { + (newTabId: SideContentTabId, prevTabId: SideContentTabId) => { // Evaluate program upon pressing the run tab. if (newTabId === SideContentType.mobileEditorRun) { handleEditorEval?.(); @@ -210,8 +214,8 @@ function MobileWorkspace(props: MobileWorkspaceProps) { const onChange = props.mobileSideContentProps.onChange; const onSideContentTabChange = useCallback( ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { onChange(newTabId, prevTabId, event); diff --git a/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx b/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx index edf05c6116..0a99967472 100644 --- a/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx +++ b/src/commons/mobileWorkspace/mobileSideContent/MobileSideContent.tsx @@ -10,7 +10,7 @@ import { type ChangeTabsCallback, type SideContentLocation, type SideContentTab, - SideContentType, + type SideContentTabId, } from '../../sideContent/SideContentTypes'; import { propsAreEqual } from '../../utils/MemoizeHelper'; import MobileControlBar from './MobileControlBar'; @@ -66,7 +66,7 @@ function MobileSideContent({ * renderedPanels is not memoized since a change in selectedTabId (when changing tabs) * would force React.useMemo to recompute the nullary function anyway */ - const renderedPanels = (dynamicTabs: SideContentTab[], selectedTabId?: SideContentType) => { + const renderedPanels = (dynamicTabs: SideContentTab[], selectedTabId?: SideContentTabId) => { // TODO: Fix the CSS of all the panels (e.g. subst_visualizer) const renderPanel = (tab: SideContentTab, workspaceLocation?: SideContentLocation) => { if (!tab.body) return; diff --git a/src/commons/sagas/LanguageDirectorySaga.ts b/src/commons/sagas/LanguageDirectorySaga.ts index 5f0283b573..7ff8eace92 100644 --- a/src/commons/sagas/LanguageDirectorySaga.ts +++ b/src/commons/sagas/LanguageDirectorySaga.ts @@ -55,27 +55,30 @@ const languageDirectoryHandlers = combineSagaHandlers({ }, [LanguageDirectoryActions.setSelectedEvaluator.type]: function* () { const evaluator = yield call(getEvaluatorDefinitionSaga); - if (evaluator?.defaultProgram == null) return; - const playground = yield select((state: OverallState) => state.workspaces.playground); - const activeTabIndex: number = playground.activeEditorTabIndex ?? 0; - const editorValue: string = playground.editorTabs[activeTabIndex]?.value ?? ''; - if (editorValue === defaultEditorValue) { - yield put( - WorkspaceActions.updateEditorValue('playground', activeTabIndex, evaluator.defaultProgram), - ); - } - }, - [LanguageDirectoryActions.setSelectedLanguage.type]: function* () { - const language = yield call(getLanguageDefinitionSaga); - if (!language) return; - if (language.evaluators.length > 0) { - yield put(LanguageDirectoryActions.setSelectedEvaluator(language.evaluators[0].id)); + + // Set the evaluator's default editor program when switching evaluators, but only while the + // editor still holds the untouched default (never clobber code the user has written). + if (evaluator?.defaultProgram != null) { + const playground = yield select((state: OverallState) => state.workspaces.playground); + const activeTabIndex: number = playground.activeEditorTabIndex ?? 0; + const editorValue: string = playground.editorTabs[activeTabIndex]?.value ?? ''; + if (editorValue === defaultEditorValue) { + yield put( + WorkspaceActions.updateEditorValue( + 'playground', + activeTabIndex, + evaluator.defaultProgram, + ), + ); + } } + // Preload the conductor for the *newly selected* evaluator, so a subsequent Run uses this + // evaluator (not the language default). Without this, picking e.g. the Stepper evaluator would + // never update the prepared conductor — the run would keep using the default evaluator, so + // `hostLoadPlugin("stepper")` would never fire and the Stepper tab would never appear. const conductorEnabled: boolean = yield select(selectConductorEnable); if (!conductorEnabled) return; - - const evaluator = yield call(getEvaluatorDefinitionSaga); if (!evaluator?.path) return; try { @@ -84,6 +87,16 @@ const languageDirectoryHandlers = combineSagaHandlers({ console.error('Failed to preload:', error); } }, + [LanguageDirectoryActions.setSelectedLanguage.type]: function* () { + // Selecting a language defaults its evaluator to the first one. The actual conductor preload + // happens in the setSelectedEvaluator handler above (this dispatch triggers it), so switching + // evaluators afterwards re-preloads the correct one. + const language = yield call(getLanguageDefinitionSaga); + if (!language) return; + if (language.evaluators.length > 0) { + yield put(LanguageDirectoryActions.setSelectedEvaluator(language.evaluators[0].id)); + } + }, }); function* LanguageDirectorySaga() { diff --git a/src/commons/sagas/SideContentSaga.ts b/src/commons/sagas/SideContentSaga.ts index ea252067cd..5143d9df2a 100644 --- a/src/commons/sagas/SideContentSaga.ts +++ b/src/commons/sagas/SideContentSaga.ts @@ -7,6 +7,7 @@ import { getLocation } from '../sideContent/SideContentHelper'; import { type SideContentLocation, type SideContentManagerState, + type SideContentTabId, SideContentType, } from '../sideContent/SideContentTypes'; import WorkspaceActions from '../workspace/WorkspaceActions'; @@ -25,7 +26,7 @@ const isVisitSideContent = ( const selectSelectedTab = ( state: any, workspaceLocation: SideContentLocation, -): SideContentType | undefined => { +): SideContentTabId | undefined => { const sideContentState = (state.sideContent ?? state) as SideContentManagerState; const [location] = getLocation(workspaceLocation); return sideContentState[location]?.selectedTab; diff --git a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts index 8fe64e85cc..1379353408 100644 --- a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts +++ b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts @@ -496,6 +496,11 @@ function* handleErrors( while (true) { const error = yield take(errorChan); yield put(actions.appendInterpreterError([toConductorSourceError(error)], workspaceLocation)); + // Signal the REPL loop that evaluation has ended due to an error. + // We dispatch beginInterruptExecution here as a safety net: the runner should + // also send a terminal status (STOPPED/ERROR) which handleStatuses will catch, + // but if it doesn't (e.g. older evaluator build), this ensures we unblock. + yield put(actions.beginInterruptExecution(workspaceLocation)); } } finally { if (yield cancelled()) { @@ -554,6 +559,7 @@ function* handleStatuses( yield put(actions.setIsRunning(isActive, workspaceLocation)); } if (isTerminalStatus) { + // Unblock the REPL loop via the shared termination signal. yield put(actions.beginInterruptExecution(workspaceLocation)); } } @@ -642,9 +648,16 @@ export function* evalCodeConductorSaga( if (stdoutTask) yield cancel(stdoutTask); if (resultTask) yield cancel(resultTask); if (errorTask) yield cancel(errorTask); - if (conduit) yield call([conduit, 'terminate']); + if (conduit) { + try { + yield call([conduit, 'terminate']); + } catch (e) { + console.warn('[conductor] failed to terminate conduit', e); + } + } } finally { yield put(actions.endInterruptExecution(workspaceLocation)); + yield put(actions.setIsRunning(false, workspaceLocation)); } } } diff --git a/src/commons/sagas/WorkspaceSaga/index.ts b/src/commons/sagas/WorkspaceSaga/index.ts index 5d3e245958..60e497345d 100644 --- a/src/commons/sagas/WorkspaceSaga/index.ts +++ b/src/commons/sagas/WorkspaceSaga/index.ts @@ -136,8 +136,10 @@ const WorkspaceSaga = combineSagaHandlers({ } }); }, - [WorkspaceActions.evalEditor.type]: ({ payload: { workspaceLocation } }) => - evalEditorSaga(workspaceLocation), + [WorkspaceActions.evalEditor.type]: { + takeLatest: ({ payload: { workspaceLocation } }) => evalEditorSaga(workspaceLocation), + }, + [WorkspaceActions.promptAutocomplete.type]: function* (action) { const workspaceLocation = action.payload.workspaceLocation; const { diff --git a/src/commons/sagas/helpers/conductorEvaluatorCache.ts b/src/commons/sagas/helpers/conductorEvaluatorCache.ts index afe1812cf5..8a0d346d25 100644 --- a/src/commons/sagas/helpers/conductorEvaluatorCache.ts +++ b/src/commons/sagas/helpers/conductorEvaluatorCache.ts @@ -1,10 +1,14 @@ import type { IConduit } from '@sourceacademy/conductor/conduit'; +import { PluginType } from '@sourceacademy/plugin-directory'; import type { SagaIterator } from 'redux-saga'; import { call } from 'redux-saga/effects'; import type { BrowserHostPlugin } from '../../../features/conductor/BrowserHostPlugin'; import { createConductor } from '../../../features/conductor/createConductor'; import type { CseMachineHostPlugin } from '../../../features/conductor/CseMachineHostPlugin'; +import { DeferredConductorTabService } from '../../../features/conductor/deferredConductorTabService'; +import { importAndRegisterWebPlugin } from '../../../features/conductor/importExternalWebPlugin'; +import { store } from '../../../pages/createStore'; type PreparedConductor = { path: string; @@ -12,6 +16,7 @@ type PreparedConductor = { hostPlugin: BrowserHostPlugin; csePlugin: CseMachineHostPlugin; conduit: IConduit; + tabService: DeferredConductorTabService; setFiles: (files: Record) => void; }; @@ -25,6 +30,19 @@ let preparedConductor: PreparedConductor | null = null; let loadingConductorPath: string | null = null; let loadingConductorPromise: Promise | null = null; let currentEvaluatorPath: string | null = null; +let activeTabService: DeferredConductorTabService | null = null; + +/** + * Makes `tabService` the sole conductor surfacing tabs in the UI, deactivating the previous one. + * Only the selected/running conductor shows its tabs; preloaded spares buffer silently until run. + */ +function activateConductorTabs(tabService: DeferredConductorTabService): void { + if (activeTabService !== tabService) { + activeTabService?.deactivate(); + activeTabService = tabService; + } + tabService.activate(); +} async function fetchEvaluatorObjectUrl(path: string): Promise { const evaluatorResponse = await fetch(path); @@ -56,17 +74,64 @@ function* cleanupPreparedConductorSaga(): SagaIterator { yield call(terminatePreparedConductor, conductorToTerminate); } +/** + * Loads a web plugin requested by the runner. The plugin's web-half URL is resolved generically + * from the plugin directory (`resolutions[WEB]`); after registering it, any side-content tab it + * exposes is surfaced to the UI. This is plugin-agnostic — no per-plugin code lives here. + */ +/** + * Resolves a plugin's web-half URL from the plugin directory. The runner may request a plugin + * before the directory has finished loading, so we poll briefly for it. + */ +async function resolveWebPluginUrl(pluginId: string): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + const url = + store.getState().pluginDirectory.pluginMap?.[pluginId]?.resolutions?.[PluginType.WEB]; + if (url) return url; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return undefined; +} + +async function loadWebPlugin( + hostPlugin: BrowserHostPlugin | undefined, + pluginId: string, + tabService: DeferredConductorTabService, +): Promise { + if (!hostPlugin) return; + const url = await resolveWebPluginUrl(pluginId); + if (!url) { + // eslint-disable-next-line no-console + console.warn( + `Conductor: no web resolution for plugin "${pluginId}" (is directory.plugin.url set?)`, + ); + return; + } + try { + // The plugin is constructed with this conductor's ITabService (third constructor arg), so any + // side-content tab it exposes registers into that service. The tab is buffered there and only + // surfaced to the UI while this conductor is the active one (see DeferredConductorTabService). + await importAndRegisterWebPlugin(hostPlugin, url, tabService); + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Conductor: failed to load web plugin "${pluginId}"`, error); + } +} + async function createPreparedConductor(path: string): Promise { const evaluatorUrl = await fetchEvaluatorObjectUrl(path); let currentFiles: Record = {}; + let hostPluginRef: BrowserHostPlugin | undefined = undefined; + const tabService = new DeferredConductorTabService(); const { hostPlugin, csePlugin, conduit } = createConductor( evaluatorUrl, async (fileName: string) => currentFiles[fileName], - (_pluginName: string) => { - // TODO: implement dynamic plugin loading + (pluginName: string) => { + void loadWebPlugin(hostPluginRef, pluginName, tabService); }, ); + hostPluginRef = hostPlugin; return { path, @@ -74,6 +139,7 @@ async function createPreparedConductor(path: string): Promise hostPlugin, csePlugin, conduit, + tabService, setFiles: (files: Record) => { currentFiles = files; }, @@ -112,8 +178,16 @@ export function* preloadConductorEvaluatorSaga(path?: string): SagaIterator { return; } + const evaluatorChanged = currentEvaluatorPath !== path; currentEvaluatorPath = path; - yield call(ensurePreparedConductorSaga, path); + const prepared: PreparedConductor = yield call(ensurePreparedConductorSaga, path); + + // On an evaluator switch, surface the newly-prepared conductor's tabs (e.g. show the Stepper's + // empty welcome tab on selection). A same-evaluator warm-up spawned after a Run leaves the active + // conductor untouched, so its populated tab is not replaced by the idle spare. + if (evaluatorChanged) { + activateConductorTabs(prepared.tabService); + } } /** @@ -138,9 +212,14 @@ export function* getPreparedConductorSaga(options?: GetPreparedConductorOptions) prepared.setFiles(files); } - // Consume only when requested (e.g. for program evaluation, not autocomplete requests). - if (consume && preparedConductor === prepared) { - resetPreparedConductor(); + // Consume only when requested (e.g. for program evaluation, not autocomplete requests). Promote + // this conductor's tabs to the UI so a Run shows the conductor that actually executed, and keep + // them shown while the next (idle) conductor is warmed in the background. + if (consume) { + activateConductorTabs(prepared.tabService); + if (preparedConductor === prepared) { + resetPreparedConductor(); + } } return { diff --git a/src/commons/sideContent/SideContent.tsx b/src/commons/sideContent/SideContent.tsx index f759051823..8d411e3353 100644 --- a/src/commons/sideContent/SideContent.tsx +++ b/src/commons/sideContent/SideContent.tsx @@ -8,7 +8,7 @@ import type { ChangeTabsCallback, SideContentLocation, SideContentTab, - SideContentType, + SideContentTabId, } from './SideContentTypes'; export type SideContentProps = { @@ -19,8 +19,8 @@ export type SideContentProps = { afterDynamicTabs: SideContentTab[]; }; onChange?: ChangeTabsCallback; - selectedTabId?: SideContentType; - defaultTab?: SideContentType; + selectedTabId?: SideContentTabId; + defaultTab?: SideContentTabId; workspaceLocation: SideContentLocation; }; diff --git a/src/commons/sideContent/SideContentActions.ts b/src/commons/sideContent/SideContentActions.ts index f420830e36..1cf1abbece 100644 --- a/src/commons/sideContent/SideContentActions.ts +++ b/src/commons/sideContent/SideContentActions.ts @@ -1,22 +1,22 @@ import { createActions } from '../redux/utils'; import type { DebuggerContext, WorkspaceLocation } from '../workspace/WorkspaceTypes'; -import type { SideContentLocation, SideContentType } from './SideContentTypes'; +import type { SideContentLocation, SideContentTabId } from './SideContentTypes'; const SideContentActions = createActions('sideContent', { - beginAlertSideContent: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + beginAlertSideContent: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), - endAlertSideContent: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + endAlertSideContent: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), visitSideContent: ( - newId: SideContentType, - prevId: SideContentType | undefined, + newId: SideContentTabId, + prevId: SideContentTabId | undefined, workspaceLocation: SideContentLocation, ) => ({ newId, prevId, workspaceLocation }), - removeSideContentAlert: (id: SideContentType, workspaceLocation: SideContentLocation) => ({ + removeSideContentAlert: (id: SideContentTabId, workspaceLocation: SideContentLocation) => ({ id, workspaceLocation, }), diff --git a/src/commons/sideContent/SideContentHelper.ts b/src/commons/sideContent/SideContentHelper.ts index d99512a5b9..206699c3e9 100644 --- a/src/commons/sideContent/SideContentHelper.ts +++ b/src/commons/sideContent/SideContentHelper.ts @@ -21,10 +21,11 @@ import type { SideContentLocation, SideContentState, SideContentTab, + SideContentTabId, } from './SideContentTypes'; import { SideContentType } from './SideContentTypes'; -const requireProvider = (x: string) => { +export const requireProvider = (x: string) => { const exports = { react: React, 'react/jsx-runtime': JSXRuntime, @@ -41,7 +42,9 @@ const requireProvider = (x: string) => { return exports[x as keyof typeof exports] as any; }; -type RawTab = (provider: ReturnType) => { default: ModuleSideContent }; +export type RawTab = (provider: ReturnType) => { + default: ModuleSideContent; +}; /** * Returns an array of SideContentTabs to be spawned @@ -72,14 +75,14 @@ export const getTabId = (tab: SideContentTab) => export const generateTabAlert = (shouldAlert: boolean) => `side-content-tooltip${shouldAlert ? ' side-content-tab-alert' : ''}`; -export const useSideContent = (location: SideContentLocation, defaultTab?: SideContentType) => { +export const useSideContent = (location: SideContentLocation, defaultTab?: SideContentTabId) => { const [workspaceLocation] = getLocation(location); const { alerts, dynamicTabs, selectedTab, height }: SideContentState = useTypedSelector( state => state.sideContent[workspaceLocation], ); const dispatch = useDispatch(); const setSelectedTab = useCallback( - (newId: SideContentType) => { + (newId: SideContentTabId) => { if ( (selectedTab === SideContentType.substVisualizer || selectedTab === SideContentType.cseMachine) && diff --git a/src/commons/sideContent/SideContentManager.ts b/src/commons/sideContent/SideContentManager.ts new file mode 100644 index 0000000000..879c3107a2 --- /dev/null +++ b/src/commons/sideContent/SideContentManager.ts @@ -0,0 +1,93 @@ +import type { ITabService, Tab } from 'src/features/conductor/commonTabs'; + +import type { SideContentLocation, SideContentTab } from './SideContentTypes'; + +type Listener = () => void; + +type RegisteredTab = { + tab: SideContentTab; + visible: boolean; +}; + +export class TabService implements ITabService { + private readonly emptyTabs: SideContentTab[] = []; + private readonly listeners = new Set(); + private readonly tabs = new Map(); + private visibleTabs: SideContentTab[] = []; + private workspaceLocation: SideContentLocation = 'playground'; + + registerTab(tab: Tab): void { + const currentTab = this.tabs.get(tab.id); + this.tabs.set(tab.id, { + tab, + visible: currentTab?.visible ?? false, + }); + this.emit(); + } + + unregisterTab(id: string): void { + if (!this.tabs.delete(id)) { + return; + } + this.emit(); + } + + showTab(id: string): void { + this.setTabVisibility(id, true); + } + + hideTab(id: string): void { + this.setTabVisibility(id, false); + } + + clearTabs(): void { + if (this.tabs.size === 0) { + return; + } + this.tabs.clear(); + this.emit(); + } + + getTabs(workspaceLocation: SideContentLocation): SideContentTab[] { + if (workspaceLocation !== this.workspaceLocation) { + return this.emptyTabs; + } + return this.visibleTabs; + } + + setWorkspaceLocation(workspaceLocation: SideContentLocation): void { + if (this.workspaceLocation === workspaceLocation) { + return; + } + this.workspaceLocation = workspaceLocation; + this.emit(); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(): void { + this.visibleTabs = Array.from(this.tabs.values()) + .filter(({ visible }) => visible) + .map(({ tab }) => tab); + this.listeners.forEach(listener => listener()); + } + + private setTabVisibility(id: string, visible: boolean): void { + const currentTab = this.tabs.get(id); + if (!currentTab || currentTab.visible === visible) { + return; + } + this.tabs.set(id, { + ...currentTab, + visible, + }); + this.emit(); + } +} + +const sideContentManager = new TabService(); + +export default sideContentManager; diff --git a/src/commons/sideContent/SideContentProvider.tsx b/src/commons/sideContent/SideContentProvider.tsx index 859165b97c..2a079b8b1c 100644 --- a/src/commons/sideContent/SideContentProvider.tsx +++ b/src/commons/sideContent/SideContentProvider.tsx @@ -1,11 +1,12 @@ -import { useCallback } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { useSideContent } from './SideContentHelper'; +import sideContentManager from './SideContentManager'; import type { ChangeTabsCallback, SideContentLocation, SideContentTab, - SideContentType, + SideContentTabId, } from './SideContentTypes'; type SideContentProviderProps = { @@ -18,7 +19,7 @@ type SideContentProviderProps = { alerts: string[]; changeTabsCallback: ChangeTabsCallback; height?: number; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; }) => React.ReactElement; /** @@ -26,12 +27,12 @@ type SideContentProviderProps = { * then responsible for managing tab changing */ onChange?: ChangeTabsCallback; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; /** * Value to use if the currently selected tab is undefined */ - defaultTab?: SideContentType; + defaultTab?: SideContentTabId; workspaceLocation: SideContentLocation; }; @@ -56,10 +57,14 @@ export default function SideContentProvider({ workspaceLocation, defaultTab, ); + const serviceTabs = useSyncExternalStore( + sideContentManager.subscribe.bind(sideContentManager), + () => sideContentManager.getTabs(workspaceLocation), + ); const allTabs = tabs - ? [...tabs.beforeDynamicTabs, ...dynamicTabs, ...tabs.afterDynamicTabs] - : dynamicTabs; + ? [...tabs.beforeDynamicTabs, ...dynamicTabs, ...serviceTabs, ...tabs.afterDynamicTabs] + : [...dynamicTabs, ...serviceTabs]; const changeTabsCallback: ChangeTabsCallback = useCallback( (newId, oldId, event) => { diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts index 531717cc0e..c201d6549d 100644 --- a/src/commons/sideContent/SideContentTypes.ts +++ b/src/commons/sideContent/SideContentTypes.ts @@ -37,6 +37,12 @@ export enum SideContentType { upload = 'upload', } +/** + * The id of a side-content tab. Built-in tabs use the {@link SideContentType} enum; dynamically + * loaded plugin tabs (via the tab service) use a free-form string id. + */ +export type SideContentTabId = SideContentType | string; + /** * @property label A string that will appear as the tooltip. * @@ -57,7 +63,7 @@ export type SideContentTab = { label: string; iconName: IconName; body: React.ReactElement | null; - id?: SideContentType; + id?: SideContentTabId; disabled?: boolean; }; @@ -93,12 +99,12 @@ export type SideContentState = { height?: number; dynamicTabs: SideContentTab[]; alerts: string[]; - selectedTab?: SideContentType; + selectedTab?: SideContentTabId; }; export type ChangeTabsCallback = ( - newId: SideContentType, - oldId: SideContentType, + newId: SideContentTabId, + oldId: SideContentTabId, event: React.MouseEvent, ) => void; @@ -106,5 +112,5 @@ export type SideContentDispatchProps = { /** * Call this function to cause the icon of the tab with the provided ID to flash */ - alertSideContent: (newId: SideContentType) => void; + alertSideContent: (newId: SideContentTabId) => void; }; diff --git a/src/commons/utils/RegisterServiceWorker.ts b/src/commons/utils/RegisterServiceWorker.ts index ae618d7f16..fd657e57ab 100644 --- a/src/commons/utils/RegisterServiceWorker.ts +++ b/src/commons/utils/RegisterServiceWorker.ts @@ -136,13 +136,25 @@ function checkValidServiceWorker(swUrl: string, config?: Config) { } export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then(registration => { - registration.unregister(); - }) - .catch(error => { - console.error(error.message); - }); + if (!('serviceWorker' in navigator)) { + return; + } + // Robustly remove ANY previously-registered service worker (e.g. from a production build or an + // earlier visit) and purge its caches. A stale SW serves the cached app shell for every request + // — including worker/evaluator scripts — so a fetched `.js` comes back as `index.html`, which + // breaks local development (notably Conductor evaluation: the evaluator Worker fails to parse + // HTML and the run hangs forever). We use getRegistrations() rather than `.ready` (which never + // resolves when there is no active SW) so every registration is caught. + navigator.serviceWorker + .getRegistrations() + .then(registrations => + Promise.all(registrations.map(registration => registration.unregister())), + ) + .catch(error => console.error('Failed to unregister service workers:', error)); + if ('caches' in window) { + caches + .keys() + .then(keys => Promise.all(keys.map(key => caches.delete(key)))) + .catch(error => console.error('Failed to clear service worker caches:', error)); } } diff --git a/src/features/conductor/Registry.ts b/src/features/conductor/Registry.ts new file mode 100644 index 0000000000..f54e061fc5 --- /dev/null +++ b/src/features/conductor/Registry.ts @@ -0,0 +1,16 @@ +import type { PluginClass } from '@sourceacademy/conductor/conduit'; + +import type { ITabService } from './commonTabs'; + +/** + * Registry of *built-in* web plugins implemented inside the frontend (e.g. autocomplete). These are + * registered directly by id without going through the plugin directory. External plugins (such as + * the stepper) are not listed here — they are resolved from the plugin directory and imported + * dynamically (see {@link createPreparedConductor} in `conductorEvaluatorCache`). + * + * Each registered plugin class is constructed with the shared {@link ITabService} so it can + * contribute side-content tabs. + */ +export type PluginRegistry = Map>; + +export const registry: PluginRegistry = new Map(); diff --git a/src/features/conductor/commonTabs.ts b/src/features/conductor/commonTabs.ts new file mode 100644 index 0000000000..45f6663606 --- /dev/null +++ b/src/features/conductor/commonTabs.ts @@ -0,0 +1,26 @@ +import type { IconName } from '@blueprintjs/icons'; +import type React from 'react'; + +/** + * Vendored from `@sourceacademy/common-tabs` (plugins PR #25). The frontend consumes these + * **types only** (every import is `import type`, erased at build time), but the local multi-repo + * yarn workspace can't link the `portal:` package (an unrelated js-slang↔conductor portal conflict + * makes `yarn install` fail), so the contract is mirrored here. + * + * When PR #3977 lands (it depends on the published `@sourceacademy/common-tabs`), replace imports of + * this module with `@sourceacademy/common-tabs` — the shapes are identical. + */ +export type Tab = { + label: string; + iconName: IconName; + body: React.ReactElement | null; + id: string; + disabled?: boolean; +}; + +export interface ITabService { + registerTab(tab: Tab): void; + unregisterTab(id: string): void; + showTab(id: string): void; + hideTab(id: string): void; +} diff --git a/src/features/conductor/deferredConductorTabService.ts b/src/features/conductor/deferredConductorTabService.ts new file mode 100644 index 0000000000..c83e86fbce --- /dev/null +++ b/src/features/conductor/deferredConductorTabService.ts @@ -0,0 +1,69 @@ +import sideContentManager from '../../commons/sideContent/SideContentManager'; +import type { ITabService, Tab } from './commonTabs'; + +/** + * Per-conductor {@link ITabService} that buffers a conductor's side-content tab registrations and + * forwards them to the global {@link sideContentManager} only while that conductor is the *active* + * one (the conductor currently selected / being run). + * + * Conductors are preloaded ahead of use, and a warm spare is created after every Run. Each spare's + * web plugin boots and registers its tab immediately; but the global manager keys tabs by plugin + * id, so a freshly-booted spare would overwrite the running conductor's populated tab — e.g. the + * Stepper's steps flash, then revert to its empty "welcome" view as the idle spare re-registers an + * empty tab. Buffering per conductor and surfacing only the active one keeps the visible tab tied + * to the conductor the user actually ran. + */ +export class DeferredConductorTabService implements ITabService { + private readonly tabs = new Map(); + private readonly shownTabIds = new Set(); + private active = false; + + registerTab(tab: Tab): void { + this.tabs.set(tab.id, tab); + if (this.active) { + sideContentManager.registerTab(tab); + } + } + + unregisterTab(id: string): void { + this.tabs.delete(id); + this.shownTabIds.delete(id); + if (this.active) { + sideContentManager.unregisterTab(id); + } + } + + showTab(id: string): void { + this.shownTabIds.add(id); + if (this.active) { + sideContentManager.showTab(id); + } + } + + hideTab(id: string): void { + this.shownTabIds.delete(id); + if (this.active) { + sideContentManager.hideTab(id); + } + } + + /** Surfaces this conductor's tabs in the UI, replacing whatever the previous active one showed. */ + activate(): void { + if (this.active) { + return; + } + this.active = true; + sideContentManager.clearTabs(); + for (const tab of this.tabs.values()) { + sideContentManager.registerTab(tab); + } + for (const id of this.shownTabIds) { + sideContentManager.showTab(id); + } + } + + /** Stops forwarding to the UI. The next conductor to activate clears and replays the manager. */ + deactivate(): void { + this.active = false; + } +} diff --git a/src/features/conductor/flagConductorEnable.ts b/src/features/conductor/flagConductorEnable.ts index 2f554db9f1..73cb93b379 100644 --- a/src/features/conductor/flagConductorEnable.ts +++ b/src/features/conductor/flagConductorEnable.ts @@ -1,10 +1,39 @@ -import { createFeatureFlag } from '../../commons/featureFlags'; +import { put } from 'redux-saga/effects'; + +import { createFeatureFlag, FeatureFlagsActions } from '../../commons/featureFlags'; import { featureSelector } from '../../commons/featureFlags/featureSelector'; +import { flagDirectoryLanguageUrl } from '../directory/flagDirectoryLanguageUrl'; +import { flagDirectoryPluginUrl } from '../directory/flagDirectoryPluginUrl'; + +/** Local directory paths served by the frontend dev server / production build. */ +const LOCAL_LANGUAGE_DIR = '/languages/directory.json'; +const LOCAL_PLUGIN_DIR = '/plugins/directory.json'; export const flagConductorEnable = createFeatureFlag( 'conductor.enable', false, 'Enables the Conductor framework for evaluation of user programs.', + function* (enabled: boolean) { + if (enabled) { + // Switch to the locally-served stepper evaluators and web plugin. + yield put( + FeatureFlagsActions.setFlag({ + featureFlag: flagDirectoryLanguageUrl, + value: LOCAL_LANGUAGE_DIR, + }), + ); + yield put( + FeatureFlagsActions.setFlag({ + featureFlag: flagDirectoryPluginUrl, + value: LOCAL_PLUGIN_DIR, + }), + ); + } else { + // Restore the production remote directories. + yield put(FeatureFlagsActions.resetFlag({ featureFlag: flagDirectoryLanguageUrl })); + yield put(FeatureFlagsActions.resetFlag({ featureFlag: flagDirectoryPluginUrl })); + } + }, ); export const selectConductorEnable = featureSelector(flagConductorEnable); diff --git a/src/features/conductor/importExternalWebPlugin.ts b/src/features/conductor/importExternalWebPlugin.ts new file mode 100644 index 0000000000..53fb921e93 --- /dev/null +++ b/src/features/conductor/importExternalWebPlugin.ts @@ -0,0 +1,77 @@ +import * as BlueprintCore from '@blueprintjs/core'; +import type { PluginClass } from '@sourceacademy/conductor/conduit'; +import React from 'react'; +import * as ReactJsxRuntime from 'react/jsx-runtime'; + +import type { BrowserHostPlugin } from './BrowserHostPlugin'; +import type { ITabService } from './commonTabs'; + +/** + * Modules the host exposes to Conductor web plugins through their `require` shim. Web plugins are + * built with React/Blueprint as externals so they reuse the host's singletons (a second React copy + * would break hooks and make the plugin's elements incompatible with the host renderer). The host + * owns these deps, which keeps plugins lightweight and free of per-plugin frontend wiring. + */ +const HOST_PROVIDED_MODULES: Record = { + react: React, + 'react/jsx-runtime': ReactJsxRuntime, + '@blueprintjs/core': BlueprintCore, +}; + +function hostRequire(moduleName: string): unknown { + const resolved = HOST_PROVIDED_MODULES[moduleName]; + if (resolved === undefined) { + throw new Error(`Conductor web plugin require()'d an unavailable module: "${moduleName}"`); + } + return resolved; +} + +type PluginExports = { plugin?: unknown; default?: unknown }; + +/** + * Resolves the plugin class from an imported web-plugin bundle, supporting two shapes: + * + * 1. a named `plugin` export — Conductor's own {@link importExternalPlugin} contract; or + * 2. a `default` require-wrapper `(require) => moduleExports` — the format emitted by the plugins + * repo build. It is an arrow function (no `prototype`), so we invoke it with the host `require` + * to obtain `module.exports`, then read the plugin class off it. + */ +function resolvePluginClass(moduleNamespace: PluginExports): unknown { + if (typeof moduleNamespace.plugin === 'function') { + return moduleNamespace.plugin; + } + + const defaultExport = moduleNamespace.default; + if (typeof defaultExport !== 'function') { + return undefined; + } + + // A require-wrapper is an arrow function, which has no `prototype`; a default-exported plugin + // class does. Only the former needs to be invoked with the host `require`. + if ((defaultExport as { prototype?: unknown }).prototype !== undefined) { + return defaultExport; + } + + const exported = (defaultExport as (require: typeof hostRequire) => PluginExports)(hostRequire); + return exported?.plugin ?? exported?.default ?? exported; +} + +/** + * Imports a Conductor web plugin bundle from `url` and registers it with the host, passing the + * shared {@link ITabService} as a constructor argument so the plugin can contribute side-content + * tabs. This is the host-side counterpart to Conductor's `importAndRegisterExternalPlugin`, which + * only understands a named `plugin` export and cannot inject the host's React/Blueprint that the + * require-wrapper bundles depend on. + */ +export async function importAndRegisterWebPlugin( + hostPlugin: BrowserHostPlugin, + url: string, + tabService: ITabService, +): Promise { + const moduleNamespace: PluginExports = await import(/* webpackIgnore: true */ url); + const pluginClass = resolvePluginClass(moduleNamespace); + if (typeof pluginClass !== 'function') { + throw new Error(`Conductor web plugin at "${url}" did not export a plugin class`); + } + hostPlugin.registerPlugin(pluginClass as PluginClass<[ITabService]>, tabService); +} diff --git a/src/index.tsx b/src/index.tsx index b2e5e74967..c47bae8dbc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,8 @@ import 'src/i18n/i18n'; import 'src/styles/global.css'; import 'src/styles/index.scss'; +// Expose shared libs for dynamically-loaded Conductor web plugins (must run before any plugin loads). +import 'src/bootstrap/conductorSharedDeps'; import { Button, OverlaysProvider } from '@blueprintjs/core'; import { setModulesStaticURL } from 'js-slang/dist/modules/loader'; @@ -10,7 +12,10 @@ import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; import Constants, { Links } from 'src/commons/utils/Constants'; import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; -import { register as registerServiceWorker } from 'src/commons/utils/RegisterServiceWorker'; +import { + register as registerServiceWorker, + unregister as unregisterServiceWorker, +} from 'src/commons/utils/RegisterServiceWorker'; import { triggerSyncLogs } from 'src/features/eventLogging/client'; import { store } from 'src/pages/createStore'; @@ -49,26 +54,34 @@ createInBrowserFileSystem(store) ); }); -registerServiceWorker({ - onUpdate: registration => { - showWarningMessage( -
- A new version of Source Academy is available.  - -
, - 0, - ); - }, -}); +if (process.env.NODE_ENV === 'production') { + registerServiceWorker({ + onUpdate: registration => { + showWarningMessage( +
+ A new version of Source Academy is available.  + +
, + 0, + ); + }, + }); +} else { + // In development we never want a service worker: a stale one (from a production build or earlier + // visit) serves the cached app shell for every request, so fetched evaluator/worker scripts come + // back as `index.html` and Conductor runs hang. Proactively unregister any SW and clear its + // caches on every dev load so this self-heals and never recurs. + unregisterServiceWorker(); +} if (Constants.cadetLoggerUrl) { // Seriously: registerServiceWorker onSuccess and onUpdate are separate paths. diff --git a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx index 964aef234d..801337564c 100644 --- a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx +++ b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx @@ -38,6 +38,7 @@ import type { SideContentProps } from '../../../../commons/sideContent/SideConte import { useSideContent } from '../../../../commons/sideContent/SideContentHelper'; import { type SideContentTab, + type SideContentTabId, SideContentType, } from '../../../../commons/sideContent/SideContentTypes'; import Workspace, { type WorkspaceProps } from '../../../../commons/workspace/Workspace'; @@ -425,8 +426,8 @@ function GradingWorkspace(props: Props) { const sideContentProps: SideContentProps = { onChange: ( - newTabId: SideContentType, - prevTabId: SideContentType, + newTabId: SideContentTabId, + prevTabId: SideContentTabId, event: React.MouseEvent, ) => { if (newTabId === prevTabId) { diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 1abe0f2c3a..a450151ee7 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -34,6 +34,7 @@ import { } from 'src/commons/utils/WarningDialogHelper'; import WorkspaceActions from 'src/commons/workspace/WorkspaceActions'; import type { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; +import { selectConductorEnable } from 'src/features/conductor/flagConductorEnable'; import CseMachine from 'src/features/cseMachine/CseMachine'; import GithubActions from 'src/features/github/GitHubActions'; import PersistenceActions from 'src/features/persistence/PersistenceActions'; @@ -82,7 +83,6 @@ import { generateLanguageIntroduction } from '../../commons/utils/IntroductionHe import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; import { type IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; import Workspace, { type WorkspaceProps } from '../../commons/workspace/Workspace'; -import { selectConductorEnable } from '../../features/conductor/flagConductorEnable'; import { initSession, log } from '../../features/eventLogging'; import type { CodeDelta, @@ -194,6 +194,10 @@ export async function handleHash( } } +// Tab id exposed by the conductor stepper web plugin. The frontend mirrors this contract to hide +// the legacy REPL/resizing when that plugin's tab is active (see usages below). +const CONDUCTOR_STEPPER_TAB_ID = 'stepper'; + function Playground(props: PlaygroundProps) { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; @@ -745,6 +749,9 @@ function Playground(props: PlaygroundProps) { ? conductorEvaluatorSupportsCse || hasCseSnapshots : languageConfig.supports.cseMachine || hasCseSnapshots; const shouldShowSubstVisualizer = languageConfig.supports.substVisualizer; + // When the Conductor framework is enabled, the stepper (and other tools) are provided by web + // plugins loaded dynamically, so the legacy in-frontend tabs are hidden in favour of plugin tabs. + const conductorEnabled = useTypedSelector(selectConductorEnable); const conductorWelcomeText = useTypedSelector(state => { if (!selectConductorEnable(state)) return null; @@ -797,9 +804,12 @@ function Playground(props: PlaygroundProps) { if (shouldShowCseMachine) { tabs.push(makeCseMachineTabFrom(workspaceLocation)); } - if (shouldShowSubstVisualizer) { + // The legacy stepper tab is only shown with the old (non-conductor) pipeline. + if (shouldShowSubstVisualizer && !conductorEnabled) { tabs.push(makeSubstVisualizerTabFrom(workspaceLocation, output)); } + // Under the conductor, tools are contributed by dynamically-loaded web plugins; their tabs are + // injected automatically by SideContentProvider (via the shared tab service), not here. } if (!isSicpEditor && !Constants.playgroundOnly) { @@ -821,6 +831,7 @@ function Playground(props: PlaygroundProps) { shouldShowDataVisualizer, shouldShowCseMachine, shouldShowSubstVisualizer, + conductorEnabled, remoteExecutionTab, editorSessionId, sessionManagementTab, @@ -989,7 +1000,10 @@ function Playground(props: PlaygroundProps) { sourceVariant: languageConfig.variant, externalLibrary: ExternalLibraryName.NONE, // temporary placeholder as we phase out libraries hidden: - selectedTab === SideContentType.substVisualizer || selectedTab === SideContentType.cseMachine, + selectedTab === SideContentType.substVisualizer || + selectedTab === SideContentType.cseMachine || + // When the conductor stepper plugin tab is active, also hide the REPL (matches legacy behaviour) + (conductorEnabled && (selectedTab as string) === CONDUCTOR_STEPPER_TAB_ID), inputHidden: replDisabled, replButtons: [replDisabled ? null : evalButton, clearButton], disableScrolling: isSicpEditor, @@ -1056,7 +1070,10 @@ function Playground(props: PlaygroundProps) { workspaceLocation, }, sideContentIsResizeable: - selectedTab !== SideContentType.substVisualizer && selectedTab !== SideContentType.cseMachine, + selectedTab !== SideContentType.substVisualizer && + selectedTab !== SideContentType.cseMachine && + // When the conductor stepper plugin tab is active, also disable resizing (matches legacy behaviour) + !(conductorEnabled && (selectedTab as string) === CONDUCTOR_STEPPER_TAB_ID), }; const mobileWorkspaceProps: MobileWorkspaceProps = { diff --git a/src/pages/playground/PlaygroundTabs.tsx b/src/pages/playground/PlaygroundTabs.tsx index 7496ea8539..1d2ae958a9 100644 --- a/src/pages/playground/PlaygroundTabs.tsx +++ b/src/pages/playground/PlaygroundTabs.tsx @@ -8,14 +8,15 @@ import SideContentSubstVisualizer from 'src/commons/sideContent/content/SideCont import { type SideContentLocation, type SideContentTab, + type SideContentTabId, SideContentType, } from 'src/commons/sideContent/SideContentTypes'; -export const mobileOnlyTabIds: readonly SideContentType[] = [ +export const mobileOnlyTabIds: readonly SideContentTabId[] = [ SideContentType.mobileEditor, SideContentType.mobileEditorRun, ]; -export const desktopOnlyTabIds: readonly SideContentType[] = [SideContentType.introduction]; +export const desktopOnlyTabIds: readonly SideContentTabId[] = [SideContentType.introduction]; export const makeIntroductionTabFrom = (content: string): SideContentTab => ({ label: 'Introduction', diff --git a/src/styles/StepperPopover.scss b/src/styles/StepperPopover.scss index 553009be57..687ffac09c 100644 --- a/src/styles/StepperPopover.scss +++ b/src/styles/StepperPopover.scss @@ -38,5 +38,6 @@ .stepper-display { font: $stepper-display-font; color: $stepper-conditional-color; + margin-bottom: 16px; } } diff --git a/src/styles/_workspace.scss b/src/styles/_workspace.scss index f55c293a88..dabe5e409b 100644 --- a/src/styles/_workspace.scss +++ b/src/styles/_workspace.scss @@ -268,7 +268,7 @@ $code-color-notification: #f9f0d7; word-break: break-word; color: $code-color-result; text-align: justify; - overflow-x: auto; + overflow-x: hidden; /* Respect padding of containing bp3 Card when scrollable */ margin-bottom: 0.4rem; @@ -406,7 +406,8 @@ $code-color-notification: #f9f0d7; // Specific CSS for the Stepper and CSE Machine tab, since REPL is hidden ##{$ns}-tab-panel_side-content-tabs_subst_visualiser, - ##{$ns}-tab-panel_side-content-tabs_cse_machine { + ##{$ns}-tab-panel_side-content-tabs_cse_machine, + ##{$ns}-tab-panel_side-content-tabs_stepper { height: calc(100% - 60px); margin-top: -45px; diff --git a/yarn.lock b/yarn.lock index fbe4ffa5ab..153b7ff572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3169,10 +3169,10 @@ __metadata: languageName: node linkType: hard -"@sourceacademy/plugin-directory@https://github.com/source-academy/plugin-directory.git#0.0.2": - version: 0.0.2 - resolution: "@sourceacademy/plugin-directory@https://github.com/source-academy/plugin-directory.git#commit=8b31c1a2abea6b42d52267c8ea343ee49ec5d857" - checksum: 10c0/29dbaddeda6efbacaacbeaf8d82816cba4f0ff080a1cdc451f6ae5c71de4c4519890d8a17a10161af0903f894f8b67ee2ae9044e6d1ca003522a05a15af42882 +"@sourceacademy/plugin-directory@https://github.com/source-academy/plugin-directory.git#0.0.3": + version: 0.0.3 + resolution: "@sourceacademy/plugin-directory@https://github.com/source-academy/plugin-directory.git#commit=bdc6343a30009fb91077410c2ea4652fea945f64" + checksum: 10c0/5534d545a900722ae5ddcb6ea822d1d20de1d464e54cf6c6774bc1ba52ff2832d6a6f1ce9815917a61b5239fb4f8a5a72d8ce02eb42c6922c39bf5bb614fa0f3 languageName: node linkType: hard @@ -7245,7 +7245,7 @@ __metadata: "@sourceacademy/c-slang": "npm:^1.0.21" "@sourceacademy/conductor": "npm:^0.5.0" "@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.10" - "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.2" + "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.3" "@sourceacademy/sharedb-ace": "npm:2.1.1" "@sourceacademy/sling-client": "npm:^0.1.0" "@sourceacademy/web-cse-machine": "npm:^1.0.0"