LMS (Courses)
Omniscio's learning-management surface, shipped as an in-repo plugin: a dashboard for whoever runs a course, editors for courses and lessons including an AI outline generator, a learner home and lesson viewer with quizzes, progress tracking with a certificate, and an importer for an existing Docebo catalogue.
What it is
The LMS surface — Screen 1 (HR-manager dashboard), Screen 2 (course editor + outline generator), Screen 3 (lesson editor: doc + quiz), Screen 4 (learner home), Screen 5 (learner lesson viewer: doc + quiz), Screen 6 (learner progress + certificate), and Screen 8 (Docebo import wizard) — ships as an in-repo plugin webview. Wave 1.
Where to find it
It ships as an in-repo plugin, so it appears inside the app as its own surface rather than as a separate website. Its screens run from the manager dashboard and the course and lesson editors through to the learner home, lesson viewer and progress view. It is gated behind a feature switch, so it stays hidden until it is turned on.
How it behaves
Feature gate
isLmsFeatureVisible() in src/main/services/lms/lms-feature-gate.ts is the sole gate. It returns true when the LMS plugin is installed AND enabled — no separate feature flag. Retired: the pre-plugin lms-panel UnreleasedFeatureId (Decision 4, split-map.md).
Deep links
omniscio://lms/* is parsed in main and forwarded to the plugin webview via the lms:route push kind. The plugin's lms-route-resolver.ts maps the parsed path to a useLmsCoursesStore.view kind (list, course-editor, lesson-editor, learner-home, learner-lesson, learner-progress, manager-dashboard, manager-per-course-table, import-wizard).
Known gaps (Wave 2)
- Active-account credential provider: the plugin's
getCredsreturnsnullin Wave 1 sogenerateOutline+doceboAiReviewdegrade to the "no api key" empty state. Wave 2 wires a real credential provider. - Docebo OAuth: opens the consent page via
window.open(Wave 1 fallback). The wizard prompts the user to paste the redirect code back. Wave 2 wires a bridge-levelopenExternalUrl+ auto-capture. - LinkedIn share button: uses
window.openfor the LinkedIn Add-to-Profile URL. Wave 2 wires the guarded external-URL bridge. - Push subscribers: not wired; wizard polls instead. Wave 2.
- i18n keys: the plugin ships English-only via the identity-passthrough
rendererTshim (src/plugins/lms/web/src/lib/i18n.ts). Every user-facing string is authored with a key + English fallback so Wave 2 can drop in a real catalog without touching call sites. - Certificate URL: the PDF lives on-disk in the user's userData folder (not a public URL LinkedIn can crawl); the LinkedIn share URL therefore omits
certUrl. Wave 2 with hosted cert URLs adds it. - Alert-actions barrel:
LMS_DOCEBO_ALERT_ACTIONSis exported but not registered inALL_ALERT_ACTION_MODULES(matches ref bug). Wave 2 or a follow-up wires it.
Notable adaptations from the ref (do NOT re-open without splitting the design)
Locked at GATE 1 (Session 1); details in _kb/kickoffs/lms/split-map.md:
- Reading A — author the plugin webview fresh from the ref, not port-then-delete.
src/renderer/src/features/lms/never existed on this branch. - Push wiring approach 1 — plugin-scoped IPC channels via
webContents.send(matchesplugin-session-status-change), not IPC channel additions. - UI primitives — plugin owns its own primitives under
src/plugins/lms/web/src/ui/**(Writer's 8 base + 4 LMS-specific + Session 2's 4 additions + Session 3's Modal/DialogShell extras). The plugin webview cannot import from the renderer kit. - DirectionalIcon dropped across every ported component — the plugin ships English-only, so raw lucide icons work.
data-ui-anchorprops dropped across every ported component — the plugin webview is an isolated React tree the anchor registry cannot see across.
For agents
Where the code lives
- Plugin webview (renderer):
src/plugins/lms/web/src/(React 19, Zustand, Tailwind, dnd-kit for drag-reorder, react-markdown for lesson bodies, @tanstack/react-virtual for large lists). - Plugin bundle:
src/plugins/lms/manifest.json+src/plugins/lms/ui/*(built withpnpm buildinweb/; tracked committed artifacts). - Plugin bridge (main):
src/plugins/lms/bridge/lms-bridge.ts— one dispatcher exposing 33 methods (course CRUD, module/lesson CRUD, enrollment + completion, manager rollup, Docebo import, AI outline, certificate). - Backend services (main):
src/main/services/lms/**— course service, learner rollup, certificate PDF generation, Docebo mapper + import orchestrator, AI outline via thegenerateOutlineservice. - CLI routes (agent-facing):
src/main/services/cli/cli-server-lms-routes.ts— 33 routes under/lms/*, each gated onisLmsFeatureVisible()+X-AMC-Source-Session-Id. - Shared schemas (Zod):
src/shared/lms/**(13 row shapes) +src/main/ipc/bridge-method-schemas.tsLMS_SCHEMAS (bridge input validation) +src/shared/plugin-bridge-response-schemas.tslms.*entries (response markers). - Shared IPC push kinds:
src/shared/ipc-channels/lms.ts— only 5 push kinds (4 import lifecycle + 1 deep-link route). Every request/response goes through the plugin bridge, NOT the shared channel barrel.
Data invariants
- A lesson is completed ONCE per enrollment.
lesson_completionscarries a UNIQUE index on(enrollment_id, lesson_id)(uniq_lesson_completions_enrollment_lesson, added by20260918203040-lms-lesson-completion-uniqueness.ts), andLmsService.createLessonCompletionreturns the completion already on record instead of inserting a second row. - Progress counts are per LESSON, never per row. Both sides must collapse completions to a set of lesson ids before counting — the learner surfaces do it in
lms-learner-utils.ts, the manager math inlms-manager-aggregation.ts(latestCompletionPerLesson). Counting raw completion rows let one lesson completed twice report 100% on a two-lesson course.
How the plugin talks to the backend
The plugin webview cannot import from src/main/** or src/renderer/** — it's an isolated React tree. It reaches the backend through the plugin bridge:
plugin webview → window.plugin.lms.<method>(input)
│
│ (preload validates response with PLUGIN_BRIDGE_RESPONSE_SCHEMAS['lms.<method>'])
▼
handleLmsBridgeCall (src/plugins/lms/bridge/lms-bridge.ts)
│
│ (input validated by LMS_SCHEMAS in bridge-method-schemas.ts)
▼
LmsService / DoceboImportOrchestrator / etc.
The plugin's lmsApi (src/plugins/lms/web/src/lib/lms-api.ts) wraps the 33 bridge calls into typed methods. Consumer-only Zustand stores under src/plugins/lms/web/src/store/ hold state; every mutation goes through lmsApi (UFC state-lifecycle-integrity).
Push events (backend-broadcast)
Five push kinds survive on the shared IPC barrel (src/shared/ipc-channels/lms.ts LMS_CHANNELS), broadcast via emitPush() on the main side:
lms:import-started/-progress/-complete/-failed— Docebo import lifecyclelms:route— deep-link forwarding (omniscio://lms/*parsed in main → pushed to the plugin webview)
Wave 1 note: the plugin webview does not yet subscribe to these push kinds. The Docebo import wizard polls getImportStatus(jobId) every 2 seconds while a job is running instead. Wave 2 wires the plugin's push subscribers through the preload bridge.
Related
Plugins in general — how they are installed, reviewed and enabled — are described on the Plugin marketplace page.
Last verified 2026-09-23