Skip to content

feat(course-editor): Course Editor for Playground Courses - #3494

Draft
Ethanlita wants to merge 33 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3420-course-editor
Draft

Ethanlita wants to merge 33 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3420-course-editor

Conversation

@Ethanlita

@Ethanlita Ethanlita commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

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-memory SpxProject, so the existing ownership rule puts the editor in effect-free mode (edits stay in memory), and the Course Editor's own Save exports the TutorialProject file 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 in index.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. assets never 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's edit namespace (…/edit/:inCourseEditorPath*): inCourseEditorPath is the node's path (empty for the course itself; under the project's root the tail is the Project Editor's own inEditorPath) — 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 sibling preview namespace (…/preview/:inEditorPath*, same page component) so the learner-side playground can drive inEditorPath exactly 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 named preview must 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 their File identity while their source is unchanged). Styling is deliberately minimal: this is the prototype designers will restyle.

Steps

  • Routes /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, checks canManageCourses, builds the author's working TutorialProject; Guided Courses are rejected with a pointer to course management.
  • Course explorer + document area (see Shell above): models/tutorial now models the course as records with typed parts claiming what they understand (TutorialProject.extraFiles keeps the rest; Video.extraFiles keeps unknown records of a package), and course-tree.ts projects them into nodes, resolves route paths to documents and computes changed paths. SpxProjectEditorHost keeps the author's editor state across document switches, maps the route tail under the project's root (from index.json) to the editor state's inEditorPath, starts route sync when the project is first opened and reopens the project where it was left.
  • Embedded project editor: SpxProjectEditorHost composes EditorState + EditorContextProvider + CodeEditorProvider + ProjectEditor for the ownerless course project (effect-free, no local cache), selected through a small registry keyed by index.json's project.type (spx only for now). The Course Editor navbar shares undo/redo and the edit-mode switch with the Project Editor (extracted from EditorNavbar as EditorHistoryButtons / EditorModeSwitch), shows an unsaved indicator, and confirms before leaving with unsaved changes (route guard + beforeunload).
    • The initial in-editor path is taken from the route, else from the course config. A path the Project Editor does not recognize yet (the example course's /simple/sprites/Lita is a Simple Mode route from module_SpxProjectEditor.ts, not implemented yet) falls back to the default selection and is reported via capture instead of blocking the editor; EditorState.selectByRoute is made public for that check.
  • Documents (layout is a placeholder for design to iterate on): text files in a plain Monaco editor (main_course.gox with 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 via POST /user/courses/playground/copilot-context on the uploaded working copy), images and other files (view/delete). Adds TutorialProject.setConfig.
  • Adding files by what they are (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, while assets/ 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.
    • .gox is now registered as text/plain in the MIME table (next to .spx), so course programs inline as data URLs like other text files instead of always being uploaded to Kodo.
  • Save: snapshot()saveFiles (unchanged files keep their universal URLs) → updateCourse(id, { content }). Only the content is sent: title and thumbnail belong to course management, and PATCH /courses/:id patches just the fields given, so the two surfaces do not overwrite each other (UpdateCourseParams is partial now). Cmd/Ctrl+S saves too; the editor refreshes its metadata from the response.
  • Preview: a snapshot of the author's current (unsaved) work is loaded into a separate TutorialProject and handed to CoursePlayground, so the course runs the real Tutorial lifecycle without touching the working copy. It replaces the editor while shown (both drive the route's inEditorPath) under a banner with a way back; the completion modal is reused; leaving preview restores the author's editor path. Verified locally with a showMessage / completeWith course. 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 reads completeWith as {feedback} while the contract says {content}, so feedback text does not show yet.
  • The Copilot knows what a course is (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 by CourseEditor and 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 through whileAuthoring, 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.
  • The author's copilot survives a preview: the playground's runner takes the copilot over with the learner's session, so the editor exports the author's session and panel state right before the playground mounts and restores them when the preview is left (Copilot.exportCurrentSession / restoreSession, feat(copilot): export and restore the current session #3497).
  • Radar metadata follows the selector conventions (feat(spx-gui): add Radar selectors #3504): stable kebab-case roles, identity in attributes (explorer-node[path="assets/videos"], folder-item, toggle-folder-button, target-folder-option), and the root name course-editor scoping selectors such as course-editor save-button.
  • Resource-layout guard hardened after a fourth review round, all three findings reproduced first: payload edits no longer count as a clash with the resource itself (the layout carries the resource's id); a package's directory must be free of records the course keeps outside the model, so a rename is refused and an upload derives orphan2 instead of overwriting orphan, with exportFiles throwing on a path claimed by both; names and kinds go through the same validatePathSegment as 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.
  • Fifth review round, all three reproduced by tests that fail before the fix: getValidName (shared, made reachable by uploads) increments the numeric suffix as digits, so a name at or above 2^53 no longer hangs the tab, and bounds its search with a separate counter; the file document follows the record's File rather 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; loadFiles places 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.
  • Sixth review round, reproduced by tests that fail before the fix: package ids are made unique when added (a copied package keeps its manifest's 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 named constructor no longer breaks export, and __proto__ is refused; setExtraFile keeps 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.
  • Seventh review round, both reproduced by tests that fail before the fix: a package defines its records with 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.
  • Eighth review round, both reproduced by tests that fail before the fix: records the model does not otherwise claim now live in 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_isReactive read back as true and crashed the explorer; one named __v_skip stopped 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 named assets can no longer take their place in a course without resources.
  • Ninth review round, reproduced by route tests that fail on the old shape: a course file or folder named preview could 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 own edit namespace next to preview (see Shell). The round's other finding, runtime state left in debug after a preview, stays with EditorPreview (Tutorial v2: SPX Project Editor support #3416), as in the earlier rounds.
  • Series preview (walk the saved courses of the series in order).

Review fixes

An adversarial review (Codex) of this branch raised five points, all confirmed against the code and fixed:

  • Atomic snapshots. 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.
  • Save vs. concurrent edits. Save uploads its snapshot and only clears the unsaved flag if no edit happened after the snapshot (revision counter); metadata comes from the same snapshot as the files.
  • Editor state outlives Preview. The project editor host stays mounted with an active flag: while previewing it hides its UI and syncs the editor state with a pausable IRouter wrapper (frozen route, dropped pushes), so undo history and selection survive a preview round trip.
  • Course switch on the same route. Route params are read before any await so the query re-runs; the current session ends as soon as a new load starts or fails; superseded loads are discarded; onBeforeRouteUpdate asks about unsaved changes when the course identity changes.
  • Monaco re-creation. The course program editor re-syncs the current code whenever the editor is re-created (e.g. language change) before installing its listeners.

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 AbortController that 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): EditorPreview does not reset EditorState.runtime on unmount, so previewing while the author's project is running brings the editor back with the panels still in debug mode. Noted in SpxProjectEditorHost next to the active handling; to be fixed in EditorPreview itself.
    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 named index.json yields the resource index2 instead 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 debug after the project UI unmounts stays with EditorPreview (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 (UpdateCourseSeriesParams now 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 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 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

@Ethanlita
Ethanlita force-pushed the issue-3420-course-editor branch from fb9f560 to c647c36 Compare September 14, 2026 06:07
@Ethanlita
Ethanlita force-pushed the issue-3420-course-editor branch from c647c36 to 770a6a4 Compare September 17, 2026 03:01
Ethanlita and others added 11 commits September 17, 2026 15:16
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>
Ethanlita and others added 2 commits September 21, 2026 13:05
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

1 active deployment
Preview – builder a1a1194e Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant