Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
83d7ee6
feat(stepper): integrate Conductor plugin tabs and dynamic loading of…
Jun 10, 2026
2d1e92c
Update src/styles/_workspace.scss
Shrey5132 Jun 16, 2026
93ef8cf
Update src/commons/sagas/helpers/conductorEvaluatorCache.ts
Shrey5132 Jun 16, 2026
b06d21a
Update src/features/conductor/pluginTabRegistry.ts
Shrey5132 Jun 17, 2026
48d92d7
chore(conductor): stop committing evaluator/plugin bundles to frontend
Jun 17, 2026
015f3be
fix(conductor): address PR review feedback
Jun 19, 2026
17de643
feat(conductor): dynamic plugin tabs + fix run-button hang and evalua…
Jun 23, 2026
d7d773d
style(conductor): fix prettier formatting
Jun 26, 2026
66ef1a2
build(conductor): depend on published conductor and plugin-directory
Jun 26, 2026
dd885f3
fix(conductor): load require-wrapper web plugins and keep the active …
Jun 28, 2026
de003ab
build(conductor): restore published dependency lockfile entries
Jun 29, 2026
0352648
fix(conductor): resolve tsc errors in conductor evaluator wiring
Jun 29, 2026
7caebc5
refactor(playground): hoist conductor stepper tab id to module scope
Jun 29, 2026
53b707f
Merge branch 'master' into feat/conductor-stepper
Shrey5132 Jun 29, 2026
a3ebee6
fix(conductor): merge duplicate setSelectedEvaluator handler from mas…
Jun 29, 2026
475955b
chore(deps): bump @sourceacademy/language-directory to 0.0.10
Jun 29, 2026
217df81
Merge branch 'master' into feat/conductor-stepper
Shrey5132 Jun 29, 2026
6e2e65f
Merge branch 'master' into feat/conductor-stepper
martin-henz Jun 30, 2026
14ddd32
Merge branch 'master' into feat/conductor-stepper
Shrey5132 Jun 30, 2026
716e0bf
Merge branch 'master' into feat/conductor-stepper
Shrey5132 Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<!--
Import map so dynamically-imported Conductor web plugin bundles resolve these bare
specifiers to the host frontend's singleton instances (see src/bootstrap/conductorSharedDeps.ts
and public/shims/*). Keeps a single React tree and identical Blueprint styling.
-->
<script type="importmap">
{
"imports": {
"react": "<%= assetPrefix %>/shims/react.mjs",
"react/jsx-runtime": "<%= assetPrefix %>/shims/react-jsx-runtime.mjs",
"@blueprintjs/core": "<%= assetPrefix %>/shims/blueprintjs-core.mjs"
}
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.0/TweenMax.min.js"
integrity="sha256-yYE5i030zFWS1QObMdcpEqiGU10EwGSEAQaYGnQBUs0="
crossorigin="anonymous"></script>
Expand Down
20 changes: 20 additions & 0 deletions public/shims/blueprintjs-core.mjs
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions public/shims/react-jsx-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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;
44 changes: 44 additions & 0 deletions public/shims/react.mjs
Original file line number Diff line number Diff line change
@@ -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__;
Comment thread
Shrey5132 marked this conversation as resolved.
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;
33 changes: 33 additions & 0 deletions rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 => {
Expand Down
20 changes: 20 additions & 0 deletions src/bootstrap/conductorSharedDeps.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
globals.__SA_REACT__ = React;
globals.__SA_REACT_JSX__ = ReactJsxRuntime;
globals.__SA_BLUEPRINT__ = Blueprint;
14 changes: 9 additions & 5 deletions src/commons/assessmentWorkspace/AssessmentWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@
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';
Expand Down Expand Up @@ -231,7 +235,7 @@
dispatch(WorkspaceActions.updateEditorValue(workspaceLocation, 0, code));
dispatch(LeaderboardActions.clearCode());
}
}, [dispatch]);

Check warning on line 238 in src/commons/assessmentWorkspace/AssessmentWorkspace.tsx

View workflow job for this annotation

GitHub Actions / lint (eslint)

React Hook useEffect has missing dependencies: 'code', 'initialRunCompleted', 'props.fromContestLeaderboard', and 'votingId'. Either include them or remove the dependency array

useEffect(() => {
if (assessmentOverview && assessmentOverview.maxTeamSize > 1) {
Expand Down Expand Up @@ -666,8 +670,8 @@
}

const onChangeTabs = (
newTabId: SideContentType,
prevTabId: SideContentType,
newTabId: SideContentTabId,
prevTabId: SideContentTabId,
event: React.MouseEvent<HTMLElement>,
) => {
if (newTabId === prevTabId) {
Expand Down Expand Up @@ -879,8 +883,8 @@

const mobileSideContentProps: (q: number) => MobileSideContentProps = (questionId: number) => {
const onChangeTabs = (
newTabId: SideContentType,
prevTabId: SideContentType,
newTabId: SideContentTabId,
prevTabId: SideContentTabId,
event: React.MouseEvent<HTMLElement>,
) => {
if (newTabId === prevTabId) {
Expand Down
12 changes: 8 additions & 4 deletions src/commons/mobileWorkspace/MobileWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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?.();
Expand Down Expand Up @@ -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<HTMLElement>,
) => {
onChange(newTabId, prevTabId, event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 30 additions & 17 deletions src/commons/sagas/LanguageDirectorySaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion src/commons/sagas/SideContentSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
Loading
Loading