Conversation
Ethanlita
force-pushed
the
issue-3420-course-editor
branch
from
September 14, 2026 06:07
fb9f560 to
c647c36
Compare
Ethanlita
force-pushed
the
issue-3420-course-editor
branch
from
September 17, 2026 03:01
c647c36 to
770a6a4
Compare
Adds /course-editor/:courseSeriesIdInput/:courseIdInput/:inEditorPath* (the trailing segment is reserved for the embedded project editor's in-editor navigation) and the page that loads a Playground Course and its series through Course APIs, checks the course-management capability, builds the author's working TutorialProject and hands it to the CourseEditor component. The component is a toolbar plus a placeholder body for now; the embedded project editor, course program and asset panes follow. Guided Courses keep the course-management modal; the page rejects them with a pointer back there. Part of goplus#3420. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add SpxProjectEditorHost, composing EditorState / EditorContextProvider / CodeEditorProvider / ProjectEditor for the ownerless course project (EffectFree editing, no local cache). The initial in-editor path comes from the route or the course config; a path the editor does not recognize yet (e.g. Simple Mode routes) falls back to the default selection instead of blocking the editor. - Register editor hosts by the embedded project type (spx only for now). - Course Editor navbar: undo/redo, edit mode switch, unsaved indicator; confirm before leaving with unsaved changes (route + beforeunload). - Extract EditorHistoryButtons and EditorModeSwitch from EditorNavbar so both editors share them; make EditorState.selectByRoute public.
- Left column with three panes next to the embedded project editor: course program (main_course.gox in a plain Monaco editor with XGo highlighting; Language Server support comes later), videos (list, add from local file, remove), and course info (title, thumbnail, initial editor path, Copilot context with generation through POST /user/courses/playground/copilot-context). - Save exports the Tutorial project, saves its files and updates the course through Course APIs; Cmd/Ctrl+S saves too. The page refreshes its course record after a save. - TutorialProject.setConfig for editing index.json fields. - Register .gox as text/plain so course programs inline like .spx files instead of always going through Kodo.
Preview loads a snapshot of the author's current (unsaved) work into a separate TutorialProject and hands it to CoursePlayground, so the course runs through the real Tutorial lifecycle without touching the working copy. The snapshot replaces the editor while shown (both drive the route's inEditorPath), under a banner with a way back; the completion modal is reused, and leaving preview restores the author's editor path.
- TutorialProject.snapshot() exports inside the embedded project's mutex, so Save, Preview and Copilot-context generation never capture an intermediate state of an in-flight transaction (undo/redo, imports). - Save works on that snapshot and only clears the unsaved flag when no edit happened after the snapshot was taken (revision counter); title and thumbnail come from the same snapshot as the files. - The author's EditorState now outlives the preview: the project editor host stays mounted with an `active` flag, hides its UI while previewing and syncs the editor state with a pausable router wrapper, so undo history and selection survive a preview round trip. - The page reads route params before any await so switching to another course on the same route re-runs the query, ends the current session as soon as a new load starts or fails, and discards superseded loads; a course change also asks about unsaved changes (onBeforeRouteUpdate). - The course program editor re-syncs the current code whenever Monaco is re-created (e.g. on language change) before installing its listeners.
Saving now covers the editor with a loading mask, refuses route changes (with a hint) while in flight and disables Preview, so nothing changes underneath the upload. The save is bound to an AbortController that is aborted when the editor unmounts, so a save that outlives its session can no longer publish a stale snapshot over a newer one. Also note the known gap that EditorPreview does not reset the runtime state on unmount (owner: goplus#3416), which shows when a running project is previewed and then resumed.
The Course Editor shell becomes an explorer plus a document area: the tree lists course info, the course program, the videos (folder and one node per video) and the embedded project as an opaque node; selecting a node opens its document on the right (form, code editor, video player with rename/delete, or the full Project Editor). The open document comes from the route: `inCourseEditorPath` is `program | info | videos[/<name>] | project[/<inEditorPath>]`, where the tail after `project` is the Project Editor's own in-editor path. The project host maps that tail to and from the `inEditorPath` the editor state expects, starts route sync the first time the project document is opened, keeps the editor state alive across document switches and reopens the project where it was left. Preview gets its own route record (`.../preview/:inEditorPath*`) sharing the page component, so the playground can drive `inEditorPath` as it does for learners and browser history enters and leaves preview; the unsaved-changes guard is a global guard that only fires when leaving this course's editor and preview routes.
Each explorer node shows a dot when its document differs from the baseline taken at load and after every successful save: course info (title, thumbnail, config), the course program, the videos folder and each video (added, renamed or replaced), and the embedded project (its memoized export identity). A save made while editing keeps the marks of what was edited after the snapshot.
The Tutorial project is now modelled as a collection of records with typed parts claiming the records they understand (config, course program, embedded project root, video packages); records nobody claims are kept in `extraFiles` and written back as they are, so a course carrying files this editor does not understand is never silently trimmed. Video packages likewise carry unknown records of their directory along, also when renamed. Generated records (config, program, video manifests) keep their `File` identity while their source is unchanged, which makes per-record change detection a plain identity comparison. The course explorer is a projection of those records: the course itself is the tree root (its settings from `index.json`; title and thumbnail belong to course management), the embedded project and each video are opaque package nodes, `main_course.gox` and any unclaimed record are file nodes (unclaimed ones flagged as unused), folders expand and collapse. The route's `inCourseEditorPath` is the node's path; the project's root comes from the course config. Documents are picked by node kind: course settings, folder (videos folder offers "Add video..."), video player, text editor, image viewer or a plain file view with delete; an unknown path shows a way back. Per-node unsaved dots come from comparing exports with the baseline. Save sends only `content`: title and thumbnail are edited in course management, and `PATCH /courses/:id` patches the fields given, so the two surfaces no longer overwrite each other. `UpdateCourseParams` is made partial accordingly, and the editor refreshes its metadata from the response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Uploads follow the ownership of the tree, so the format's invariants hold by construction: `assets/` only contains packages (uploading into a resource-kind folder such as `assets/videos` creates a package with its generated manifest, whatever the file's extension; `assets/` itself, unknown kinds and package directories refuse uploads), the embedded project's directory belongs to the Project Editor, fixed-path records have their own editors, and anywhere else the file becomes a plain record kept with the course but unused by it. New folders are not a feature of their own: a directory exists as soon as a record's path names it. The upload flow picks the files, then a modal chooses the target folder (proposed from the open node; the tree's accepting folders are listed and any path can be typed), explains what will happen (packages, replaced records, unused files) and refuses invalid targets; the first created node is opened. "Add video..." in the videos folder is the same flow with that folder proposed, and the explorer header offers "Upload..." for the course as a whole. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The modal opens right away with the target folder proposed and offers "Select files..." (and "Select again..."), so choosing what to upload and where happens in one place; Upload stays disabled until files are chosen and the target is valid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An adversarial review found three reproducible defects in the guard, all of them in what the rules can see: Payload edits were rejected as a clash with the resource itself. `setFile` passes the resource's current name to the uniqueness rule, which found the very resource being updated, so editing the text of any resource already in a course threw and the model kept the old content while the editor showed the new one. The layout now carries the resource's `id` and the rule ignores it. Renaming or uploading could silently overwrite a record the course keeps outside the model. Uniqueness only looked at resources, so a package could take the directory of an unclaimed record (an `assets` directory without a manifest), and the export wrote the package over it. A new rule requires the package's directory to be free, which also makes name derivation pick `orphan2` on upload instead of replacing `orphan`. `exportFiles` now throws on a path claimed by both, so a future hole in the rules cannot lose data quietly. Names could be `.` or `..`, which `export()` wrote literally and `Resource.load` resolved to another key: the saved course could not be loaded at all, by the editor or by a learner. Names and kinds now go through the same `validatePathSegment` as SPX asset names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A fifth review round found three reproducible defects; each is reproduced by a test that fails before this change. Uploading a file named with a large number twice hung the tab. `getValidName` parsed the numeric suffix into a `Number`; past `Number.MAX_SAFE_INTEGER`, `n + 1 === n`, so every candidate was the original name and its own loop guard (`i - initialNum > 10000`) never tripped because `i` never moved. It now increments the suffix digits as a string, exact at any length, and bounds the search with a separate attempt counter. `getValidName` is shared code that this branch made reachable through uploads; its only other caller was the video model this branch replaced. Replacing a text record by uploading over it lost the upload on the next keystroke. The file document is keyed by path and only watched the path, so an upload at the same path neither reloaded nor remounted it: the editor kept the old text and the next edit wrote it back over the upload. It now follows the record's `File` and reloads on an outside replacement, while recognizing the `File` it wrote itself so the author's own edits do not reset the editor. Loading named packages against the previous load's state. `loadFiles` built the new unclaimed records but assigned them only after adding the resources, so the directory-occupancy rule checked stale records: a package renamed on load could take a directory an orphan occupied (and the editor then failed to open, on the export guard added in the previous round), and reloading an instance could rename a valid package because an orphan used to sit there. The records now go in first, and intact packages are added before the ones needing a new name, so a renamed package can no longer take the name of a valid one listed after it. The load order is kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sixth review round found four reproducible defects; the new tests fail on the code before this change (14 of 15; the fifteenth guards the ordinary edit path the new file/folder rule must keep open). Packages copied along with their manifest share a `builder_id`. Deleting the second one removed the first, and worse, the name rule's "ignore myself", added in an earlier round, took the other package for itself: a rename onto its name went through and the export silently kept only one of the two. Ids are now made unique when a package is added to a course, the name rule tells a package apart from itself by instance, and the export refuses any path two parts of the course claim, packages included. Records named after object properties broke the export. The collision check added in an earlier round read `files[path]` on a plain object, so a file named `constructor` looked already present and the export threw, blocking saving and even opening such a course. Record maps are now queried for their own entries only (`hasRecord`), and `__proto__`, which a plain object turns into a prototype change instead of an entry, is refused as a file name. A file and a folder could share a path: uploading into `notes.md` created `notes.md/…`, after which the file could no longer be opened in the explorer. `setExtraFile` now refuses a new path that would put something below a file or a file where records exist below it (replacing a record at its own path is unaffected), and the upload modal reports it for the target folder and for each file. A name at the length limit could not be de-duplicated: every suffixed candidate was too long, so the second upload failed after exhausting the search, and a batch kept the files added before the failure. Name derivation now leaves room for the suffix, and an upload either adds all its files or none of them, restoring what it replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kage folders A seventh review round found two gaps inside resource packages; the three new tests fail on the code before this change. A record named `__proto__` inside a package vanished on load. Its full path survives loading the course, but `Resource.load` assigned the record under its relative name to a plain object, which set the object's prototype instead of adding an entry. The package now defines its entries with `Object.fromEntries`, so the record is kept through load, export and reload. A rename could make a file and a folder share a path inside a package: with a folder `intro.txt/` in the package, renaming the resource to `intro` gave its payload that same path, because the payload rule only compared exact names. The rule now also refuses a payload path that is a folder of the package's other records; changing the payload's extension is covered by the same rule. The export still checks exact paths only, and says why: a file and a folder sharing a path is refused where records are written, but may already exist in a course as loaded, and refusing to export would leave such a course impossible to open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ers the course keeps An eighth review round found two gaps; the five new tests fail on the code before this change. Author-chosen file names were object properties, and on a reactive model some property names are Vue's. Reading a record named `__v_isReactive` (or `__v_isReadonly`, `__v_isShallow`, `__v_raw`) returned Vue's flag instead of the file: the export wrote `true` and the explorer crashed. A record named `__v_skip` made Vue stop wrapping the map, so later edits went untracked and the course no longer showed as unsaved, letting a page leave lose work. This is the third round in a row to hit a special property name (`constructor` and `toString`, then `__proto__`), so instead of another entry in a list, the two maps holding records the model does not otherwise claim, `TutorialProject.extraFiles` and `Resource.extraFiles`, are now `Map<string, File>`. A reactive Map treats keys as data, so no file name is special any more. The plain `Files` objects at the model's edges are unaffected: the export and loaded files are never made reactive. A file named `assets` could be uploaded to a course without resources. The folder conflict check only sees records, and with no resources nothing is under `assets`, yet the explorer always shows `assets/videos`: the file could not be opened any more, and every video upload was refused. The model now reserves the folders it always treats as such (`isReservedDirectory`: `assets` and `assets/videos`), refused as file paths by `setExtraFile` and by the upload modal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A ninth review round found that a course file or folder named `preview` could not be addressed. The editing route put the node path right after the course, `/course-editor/<series>/<course>/<node path>`, on the same level as the preview segment. vue-router ranks a static segment above a param, so the editor's own link to such a node was read back as the preview on reload, a shared link or browser history, and the author landed in preview instead of the file. Editing and preview are now sibling namespaces under the course: `/course-editor/<series>/<course>/edit/<node path>` and `.../preview/<in-editor path>`. Node paths are course content and never share a level with a namespace name again. The course's own address, without a namespace, redirects to the course root in the editor. Addresses of the old shape with a node path now reach the app's 404 page; the editor has not shipped, so no link in the wild uses them. The route records are exported as `courseEditorRoutes` so a test can check them. Three of the four new tests fail on the old route shape, with the node named `preview` read back as the preview; the fourth checks that the preview keeps its own route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a file is The explorer was still a file tree with a friendlier vocabulary: the author read `assets`, `assets/videos`, `main_course.gox`, and to upload anything they had to pick or type a target folder. Nothing about a course requires that. Directories are how a course is exported, not what it is made of. The tree is now the parts of a course, in a fixed order: the course program, one group per resource kind, the embedded project, and a heading collecting the records the format gives no role to. `assets` is gone from the view, and an unclaimed record no longer hides in the directory it happens to sit in. Every node is named by what it is (`getNodeLabel`); only the records the course does not use are named by path, because they have nothing else to go by and where they came from is worth knowing. Uploading asks what the author is adding, not where it goes. The three types are a video, a picture and anything else; the type decides the directory and whether a manifest is generated, so no path is typed and none is shown. The rules underneath are unchanged, and the modal still refuses a file whose name would take a path the course claims. The pictures group is offered next to the videos group, so `assets/images` is now a directory the course keeps (`isReservedDirectory`), the way the videos one already was. No course-program call addresses a picture yet; the group and the document say so. Six new tests cover the projection, the labels, the lookup across groups and the upload types. `normalizeDir` goes: with no folder to type, nothing normalizes author-typed directories any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ourse In the Course Editor the assistant knew nothing about courses. It saw a page it had no model for: no idea what a course is made of, what the program can call, or which course the author is even looking at. Asking it for help with a course program was asking it to guess. Two halves, both following what the Project Editor already does. The knowledge is a built-in skill, `tutorial-course`: a short main document about what a course is made of and how one runs, plus two references loaded only when needed, one for the program (the API surface, the execution model, and the XGo rules that decide whether a program compiles) and one for the format (settings, resources, what is kept but unused, what saving does). It deliberately does not list which calls Preview implements today: that list moves with goplus#3419, and a snapshot of it would go stale in the skill while being wrong in a way nobody would notice. The skill says instead that a compiled course may still stop at a call the host has not wired yet. The context is `useCourseEditorCopilot`, registered by `CourseEditor` and disposed with it: which course this is and what it carries, the course program as it stands including unsaved edits, and which document the author has open. Opening the embedded project mounts the Project Editor, which adds its own context and skills and takes them away again on the way out, so the two never have to know about each other. Verified against a real round in the local editor: asked what the seed course's program does, the assistant answered from the actual program and caught the missing parentheses around `Copilot.generateText` that keep that program from compiling, which is exactly what the program reference warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eview A review round found that the Copilot context added for the author leaked into Preview. The Course Editor stays mounted through a preview, and its context registrations only go away when the component does, while the Copilot assembles context from every registered provider regardless of whose session is running. So the learner session the playground runner starts was being told it is helping a course author, and handed the course program and the authoring skill. That breaks the one thing Preview is for. An author previews to see what a learner sees, and judging the `copilotContext` they wrote for learners is impossible when the assistant is reading the course's source at the same time. Every registration now goes through `whileAuthoring`, which delegates while the author is editing and says nothing while previewing. The registrations themselves stay put, so leaving the preview restores the context without re-registering anything. The embedded project's own context needed nothing: its host unmounts the whole subtree when the project is not the open document, which takes its providers with it. The new test fails on the code before this change, with the author's course block reaching the learner's session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now nothing in the product linked to the Course Editor: it was reachable only by typing its URL, and course management creates guided courses only, by design. This adds the entry point the whole line was missing. It is organised by series rather than as a flat list of courses, for two reasons. The Course Editor addresses a course through the series it is written for, so a course outside a series cannot be opened at all. And the Course APIs cannot filter courses by kind, only series, so a kind tab over the course list would hide Playground Courses behind the hundreds of guided ones the instance already holds. Listing a series' courses has neither problem: it filters by series and returns them in the order learners take them. So the profile menu gains one entry. It opens the author's Playground Course series; opening one lists its courses; a course opens in the Course Editor. Creating a course creates it inside the series it will belong to and goes straight to the editor. A course cannot start empty: the editor needs a configuration to find the embedded project, the learner needs a project, and Preview needs a program that compiles. `createStarterCourseFiles` composes the starting point, a default SPX project plus a program that states the task and completes on a marker in the runtime log. It uses only calls the host implements today, so the first Preview of a new course shows the course, not an "unsupported capability" error; a test holds that line. nighca's four management modals are untouched: their forms are about guided courses (entrypoint, Copilot prompt), and the series file they import and export refuses Playground Courses, which belongs with the course package work in D4. Verified against the local backend: the series list, a series' courses in order, opening one in the editor, creating a course with its starter package (24 records uploaded, then previewed to the starter message with no capability error), and removing it again, which leaves the series as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Types the `kind` query parameter of the course list endpoints (goplus/builder-backend#353) on ListCoursesParams. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ac83c29)
The course management modal and the course series edit modal paginated the signed-in user's courses and then dropped non-guided ones client-side. Once playground courses exist that breaks both: - The management modal pages by 8 ordered by updatedAt desc. With the 8 most recently updated courses all being playground courses, its first page showed "no results" while the pager still counted every kind. - The series edit modal takes the first 100 courses; enough playground courses push guided ones out of that window. Both now pass `kind: 'guided'` so the server filters before paginating. The `filter(isGuidedCourse)` stays only to narrow `Course[]` to `GuidedCourse[]`. Requires the backend filter from goplus/builder-backend#353. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 99c6a56)
The separate "Manage Playground Courses" entry was a detour. It was built by series because the course list could not be filtered by kind: asking for a page and then filtering it in the browser left Playground Courses invisible behind the guided ones and made the page count wrong. The list endpoints filter by kind now, so that reason is gone, and one place for courses and one for series reads better than a second, parallel surface. "Manage courses" and "Manage course series" each gain a kind filter, guided or Playground, and the server does the filtering, so pages and totals describe what is shown. Creating follows the filter: a guided course opens the form it always had; a Playground Course asks for a title, a thumbnail and the series it is written for, starts from the starter package, and goes straight to the Course Editor. The series editor lists the courses of the series' own kind and keeps its picker and ordering; its file import and export stay with guided series, since that file has no shape for a Playground Course yet. One constraint shaped the form. The Course Editor opens a course through its series, so a course in no series could be listed but not edited. Asking for the series at creation means every course created here opens at once, and a Playground series may now be created empty so there is one to choose. Opening a course from the flat list looks its series up, and says so when a course is in none. The series-centred modal, its series form and the profile menu entry are removed. Clicking a guided course still does what it did before. Relies on the `kind` filter of the course list endpoints (goplus/builder-backend#353). Against a backend without it the parameter is ignored and the lists behave as they did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The kind filter in "Manage courses" and "Manage course series" was a pair of filter chips, which was the wrong control twice over. Chips read as independent conditions, so they sit apart from each other and nothing says the two belong together; and the modal header lays its slot content out side by side without spacing, so the chips touched the create button. Guided and Playground are two views of one list, exactly one of which is shown. That is what `UITabRadioGroup` is for, and the code editor's input helper already uses it for the same kind of choice: one trough, the selected option raised, both options the same width. A margin separates it from the create button. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and retry safely A review round found two problems in how a new Playground Course joins its series; the five new tests describe both. The series was read when the create form opened and written back whole when it was submitted, minutes later once the starter package had uploaded. A series changed in the meantime, from another tab or device, lost whatever had been added to it: the late write put back the old course list plus the new course, and a course dropped that way can no longer be opened from the list. It also wrote back the title, thumbnail, description and order it had read, overwriting edits it had nothing to do with. `appendCourseToSeries` reads the series right before writing it and sends its course list alone, which the endpoint has always accepted, so `UpdateCourseSeriesParams` says so now. What is left is the gap between that read and that write; closing it takes a server-side append the Course APIs do not have. Creating a course and adding it to a series are two requests. When the second failed, the author was told that creating the course failed, which it had not, and clicking "Create" again uploaded the starter package and created a second course, leaving the first in no series. `PlaygroundCourseCreation` remembers the course once it exists, so a retry only repeats what is left, and the message says the course exists and that retrying will not create another. A retry after a response that never arrived finds the course already in the series and writes nothing. Checked against the local backend: a patch carrying only `courseIDs` is accepted and leaves the other fields as they were, and a second run returns the same course. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Course Editor for Playground Courses (#3420): the authoring surface that edits a Course's Tutorial project (embedded SPX project, course program, videos, settings, other files) and saves it through Course APIs. Course metadata (title, thumbnail) stays with course management.
Architecture
Per
docs/develop/tutorial-v2/index.md, the Course Editor composes the existing SPX Project Editor rather than modifying it: the course's embedded project is loaded as an ownerless in-memorySpxProject, so the existing ownership rule puts the editor in effect-free mode (edits stay in memory), and the Course Editor's own Save exports theTutorialProjectfile collection and persists it via Course APIs. Preview snapshots the working copy and runs the same Tutorial lifecycle as learning.Shell (decided with the product owner, implemented as the design itself): a course explorer on the left and one document at a time on the right. The explorer is a projection of the course's records (the
FileCollection), following one rule: every record belongs to exactly one node, and a node may stand for several records. The course itself is the tree root; its document edits the settings stored inindex.json(title and thumbnail are course metadata, edited in course management). The tree shows the parts of a course, not the directories its records export to, in a fixed order: the course program, one group per resource kind (videos and pictures are always offered, plus any kind a course brought along), the embedded project, and a heading collecting the records the format gives no role to.assetsnever appears, and every node is named by what it is; only the unused records are named by path, because they have nothing else to go by and where they came from is worth knowing. The embedded project and each resource package are opaque nodes over their directories (like macOS bundles: the manifest inside never shows). Selecting a node opens its document: course settings, a resource group (which offers adding another of its kind), a video player with rename/delete, a text editor, an image viewer, or a plain file view with delete; the project node opens the full Project Editor. Unclaimed records are kept and written back as they are (also inside video packages), so a course carrying files this editor does not understand, or produced by a newer format, is never silently trimmed — the previous typed-model-only export dropped them. The open node is part of the route, under the course'seditnamespace (…/edit/:inCourseEditorPath*):inCourseEditorPathis the node's path (empty for the course itself; under the project's root the tail is the Project Editor's owninEditorPath) — naming follows "inPath": every editor has one, and the Course Editor's path embeds the Project Editor's when the project is open. Preview lives in the siblingpreviewnamespace (…/preview/:inEditorPath*, same page component) so the learner-side playground can driveinEditorPathexactly as it does for learners, and browser history enters and leaves preview. The two namespaces are siblings because a node path is course content: a file or folder namedpreviewmust never be read back as the preview. The course's own address redirects to the course root in the editor. Explorer nodes carry a dot while their records have unsaved changes (records are compared by identity with the baseline taken at load and after each save; generated records — config, program, video manifests — keep theirFileidentity while their source is unchanged). Styling is deliberately minimal: this is the prototype designers will restyle.Steps
/course-editor/:courseSeriesIdInput/:courseIdInput/edit/:inCourseEditorPath*(editing) and…/preview/:inEditorPath*(preview), with the course's bare address redirecting to the editing root, and page: loads course + series via Course APIs, checkscanManageCourses, builds the author's workingTutorialProject; Guided Courses are rejected with a pointer to course management.models/tutorialnow models the course as records with typed parts claiming what they understand (TutorialProject.extraFileskeeps the rest;Video.extraFileskeeps unknown records of a package), andcourse-tree.tsprojects them into nodes, resolves route paths to documents and computes changed paths.SpxProjectEditorHostkeeps the author's editor state across document switches, maps the route tail under the project's root (fromindex.json) to the editor state'sinEditorPath, starts route sync when the project is first opened and reopens the project where it was left.SpxProjectEditorHostcomposesEditorState+EditorContextProvider+CodeEditorProvider+ProjectEditorfor the ownerless course project (effect-free, no local cache), selected through a small registry keyed byindex.json'sproject.type(spx only for now). The Course Editor navbar shares undo/redo and the edit-mode switch with the Project Editor (extracted fromEditorNavbarasEditorHistoryButtons/EditorModeSwitch), shows an unsaved indicator, and confirms before leaving with unsaved changes (route guard +beforeunload)./simple/sprites/Litais a Simple Mode route frommodule_SpxProjectEditor.ts, not implemented yet) falls back to the default selection and is reported viacaptureinstead of blocking the editor;EditorState.selectByRouteis made public for that check.main_course.goxwith XGo highlighting; completion/diagnostics wait for the Tutorial Language Server), videos (player with rename/remove; the program refers to a video by its name, e.g.showVideo "step-to"), course settings (initial editor path, Copilot context with generation viaPOST /user/courses/playground/copilot-contexton the uploaded working copy), images and other files (view/delete). AddsTutorialProject.setConfig.upload.ts,CourseUploadModal): the author says what they are adding — a video, a picture, or anything else — and the type decides where the records go, so no path is typed and none is shown. Underneath, the rules still follow the ownership of the tree so the format's invariants hold by construction.assets/only contains packages: a video or a picture becomes a package (Resource) with its generated manifest whatever the extension, whileassets/itself and package directories refuse uploads, so the editor can never produce a directory without a manifest; the embedded project's directory belongs to the Project Editor; fixed-path records (index.json,main_course.gox) have their own editors; anything else becomes a plain record, kept with the course but unused by it, and the modal refuses a file whose name would take a path the course claims. Uploads are all-or-nothing..goxis now registered astext/plainin the MIME table (next to.spx), so course programs inline as data URLs like other text files instead of always being uploaded to Kodo.snapshot()→saveFiles(unchanged files keep their universal URLs) →updateCourse(id, { content }). Only the content is sent: title and thumbnail belong to course management, andPATCH /courses/:idpatches just the fields given, so the two surfaces do not overwrite each other (UpdateCourseParamsis partial now). Cmd/Ctrl+S saves too; the editor refreshes its metadata from the response.TutorialProjectand handed toCoursePlayground, so the course runs the real Tutorial lifecycle without touching the working copy. It replaces the editor while shown (both drive the route'sinEditorPath) under a banner with a way back; the completion modal is reused; leaving preview restores the author's editor path. Verified locally with ashowMessage/completeWithcourse. Known limits: a blocking course modal has to be dismissed before the banner is reachable; the example course cannot be previewed until the runner supports the remaining capabilities (filterAPIs,showPrelude,showVideo) and Simple Mode routes (Tutorial v2: Tutorial lifecycle and Course playground #3419 / Tutorial v2: SPX Project Editor support #3416); the runner still readscompleteWithas{feedback}while the contract says{content}, so feedback text does not show yet.utils/tutorial/skills/tutorial-course,components/course-editor/copilot): a built-in skill carrying a short main document plus two references loaded on demand, one for the course program (API surface, execution model, and the XGo rules that decide whether a program compiles) and one for the format (settings, resources, records kept but unused, what saving does); and a context registration that tells it which course this is and what it carries, the program as it stands including unsaved edits, and which document is open. Registered byCourseEditorand disposed with it, next to the Project Editor's own copilot setup, which comes and goes with the embedded project. The skill deliberately lists no capability status: which calls Preview implements moves with Tutorial v2: Tutorial lifecycle and Course playground #3419, so it says a compiled course may still stop at a call the host has not wired yet. Verified live: asked about the seed course's program, the assistant answered from the actual program and caught the missing parentheses that keep it from compiling. A review round then caught the one lifecycle gap this opens: the Course Editor stays mounted through a preview, and the Copilot assembles context from every registered provider whatever session is running, so the learner session was being handed the author's context and skill. Every registration now goes throughwhileAuthoring, silent while previewing, so Preview shows what a learner's assistant would see; the embedded project's own context needed nothing, since its host unmounts the subtree with the document.Copilot.exportCurrentSession/restoreSession, feat(copilot): export and restore the current session #3497).explorer-node[path="assets/videos"],folder-item,toggle-folder-button,target-folder-option), and the root namecourse-editorscoping selectors such ascourse-editor save-button.id); a package's directory must be free of records the course keeps outside the model, so a rename is refused and an upload derivesorphan2instead of overwritingorphan, withexportFilesthrowing on a path claimed by both; names and kinds go through the samevalidatePathSegmentas SPX asset names, so.and..can no longer produce a course that cannot be loaded. Each has a regression test, including an export-then-load round trip.getValidName(shared, made reachable by uploads) increments the numeric suffix as digits, so a name at or above2^53no longer hangs the tab, and bounds its search with a separate counter; the file document follows the record'sFilerather than only its path, so uploading over the open text record shows the upload instead of letting the next keystroke write the old text back;loadFilesplaces this course's unclaimed records before naming packages and adds intact packages first, so a load-time rename can no longer take an orphan's directory (which left the editor unable to open the course), be triggered by a previous load's orphan, or take the name of a valid package listed after it.builder_id), the name rule tells a package from itself by instance, and the export refuses any path claimed by two parts of the course, packages included; record maps are queried for their own entries only (hasRecord), so a file namedconstructorno longer breaks export, and__proto__is refused;setExtraFilekeeps a file and a folder from sharing a path, and the upload modal reports it; name derivation leaves room for the de-duplicating suffix, and an upload adds all its files or none.Object.fromEntries, so one named__proto__is no longer dropped on load; the payload rule also refuses a payload path that is a folder of the package's other records, so a rename or an extension change cannot make a file and a folder share a path inside a package. The export keeps checking exact paths only, by design: a file/folder overlap may already exist in a course as loaded, and refusing to export would make that course impossible to open, so it is refused where records are written instead.Map<string, File>(TutorialProject.extraFiles,Resource.extraFiles) instead of objects, because on a reactive model some property names are Vue's (a record named__v_isReactiveread back astrueand crashed the explorer; one named__v_skipstopped change tracking, so edits no longer showed as unsaved). This was the third round to hit a special property name, so the class is closed rather than another name blocklisted. The model also reserves the folders it always treats as such (assets,assets/videos), so a file namedassetscan no longer take their place in a course without resources.previewcould not be opened by its address. Node paths sat on the same level as the preview segment, and vue-router ranks a static segment above a param, so the editor's own link to such a node read back as the preview on reload, a shared link or browser history. Editing now has its owneditnamespace next topreview(see Shell). The round's other finding, runtime state left in debug after a preview, stays withEditorPreview(Tutorial v2: SPX Project Editor support #3416), as in the earlier rounds.Review fixes
An adversarial review (Codex) of this branch raised five points, all confirmed against the code and fixed:
TutorialProject.snapshot()exports inside the embedded project's mutex; Save, Preview and Copilot-context generation use it, so an in-flight undo/redo or import can no longer leak an intermediate state into what gets uploaded or previewed.activeflag: while previewing it hides its UI and syncs the editor state with a pausableIRouterwrapper (frozen route, dropped pushes), so undo history and selection survive a preview round trip.awaitso the query re-runs; the current session ends as soon as a new load starts or fails; superseded loads are discarded;onBeforeRouteUpdateasks about unsaved changes when the course identity changes.Second round (two more findings, both confirmed):
Save is blocking and session-bound. While saving, a loading mask covers the editor, route changes are refused with a hint and Preview is disabled; the save is tied to an
AbortControllerthat is aborted on unmount, so a save that outlives its session cannot publish a stale snapshot over a newer one. (A PATCH already on the wire cannot be recalled; a version-conditional update on the Course API is noted for Release 2.)Runtime state after Preview (not fixed here, owner Tutorial v2: SPX Project Editor support #3416):
EditorPreviewdoes not resetEditorState.runtimeon unmount, so previewing while the author's project is running brings the editor back with the panels still in debug mode. Noted inSpxProjectEditorHostnext to theactivehandling; to be fixed inEditorPreviewitself.Third round (after the records-and-mounts explorer and uploads):
Copilot-context generation is bound to its document. The snapshot/upload/request chain is aborted when the config document is closed, the inputs are locked while it runs, and a late result is written back only if the context did not change meanwhile.
Package-layout guard. The rules a resource package must satisfy live in one ordered list (
validateResourceLayout: well-formed name, unique within its kind, payload path clear of the manifest and of the package's extra records) and every mutation goes through it (setName,setFile, name derivation on upload, the rename document);export()refuses a layout that would overwrite a record. Uploading a file namedindex.jsonyields the resourceindex2instead of a course that cannot be reloaded.Async flows bound to the session. Preview snapshot loads carry a generation invalidated by leaving/re-entering the preview or unmounting; out-of-date loads dispose their snapshot instead of publishing and navigating, failed loads dispose what they created. The upload and completion modals (app-level provider) drop their results once the session ended.
Runtime state left in
debugafter the project UI unmounts stays withEditorPreview(Tutorial v2: SPX Project Editor support #3416): the side effect is created there, so its cleanup belongs there; this branch adds no compensation.Walkthrough comments (
docs(course-editor): temporary walkthrough comments): a comment-only commit with a docstring per function/component and per-block notes, added to read the branch; it will be dropped before merge.A way in (
components/course/management,components/course-editor/starter.ts): Playground Courses are managed where courses are managed. "Manage courses" and "Manage course series" each gain a kind filter (guided / Playground), and the server does the filtering, so pages and totals describe what is shown. Creating follows the filter: a guided course opens the form it always had; a Playground Course asks for a title, a thumbnail and the series it is written for, starts from a starter package (a default SPX project plus a program that compiles and uses only calls the host implements today, so the first Preview shows the course rather than an "unsupported capability" error) and goes straight to the Course Editor. The series is asked for at creation because the editor opens a course through its series, so a course in none could be listed but not edited; a Playground series may be created empty so there is one to choose, and opening a course from the list looks its series up. Adding the new course to its series reads the series right before writing it and sends its course list alone, so a series edited elsewhere while the form was open keeps those edits (UpdateCourseSeriesParamsnow says what the endpoint always accepted: every field is optional). Creating the course and adding it are two requests, so the form remembers a course it has already created: when the addition fails, the author is told the course exists, and clicking "Create" again only repeats the addition instead of creating a second course. What remains is the gap between that read and that write; closing it takes a server-side append the Course APIs do not have. The series editor lists courses of the series' own kind and keeps its picker and ordering; its file import/export stays with guided series until the course package work. An earlier cut of this step was a separate, series-centred modal, built that way because the course list could not be filtered by kind; the filter now exists, and that modal is gone. Depends on thekindfilter of the course list endpoints (goplus/builder-backend#353); against a backend without it the parameter is ignored and the lists behave as before. This step also fixes a bug the existing guided list already had: it filtered each page by kind in the browser, so with Playground Courses being the most recently updated, the first page of "Manage courses" came back empty while the pager counted every course.Verified locally against the builder-backend Tutorial v2 branches (goplus/builder-backend#350 / #353) with a Playground Course seeded from
docs/develop/tutorial-v2/example-tutorial-course.🤖 Generated with Claude Code