From 7ee76aaa66386e3a99526d174c45e9e814bb6baa Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Mon, 24 Aug 2026 15:44:34 -0700 Subject: [PATCH 01/10] Split Learning reset into exercise and unit - resetExercise now restores only current activity - copilot can now reset a whole unit and can target a unit by name - resetting something with no code, like a markdowncell, will now report instead of silently resetting another cell --- source/vscode/ai/qdk-learning.agent.md | 4 +- source/vscode/package.json | 36 +++++- source/vscode/src/gh-copilot/learningTools.ts | 119 ++++++++++++++++++ source/vscode/src/gh-copilot/tools.ts | 14 ++- source/vscode/src/learning/commands.ts | 32 ++++- .../vscode/src/learning/notebookExercises.ts | 47 +++++++ .../src/learning/python/materialization.ts | 99 ++++++++++++++- source/vscode/src/learning/service.ts | 117 ++++++++++++++--- source/vscode/src/telemetry.ts | 9 +- 9 files changed, 448 insertions(+), 29 deletions(-) diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index 2c3322e4873..ff4e3660642 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -100,7 +100,8 @@ Call `get-state` first. If the user is asking to navigate, run, check, reset, et - **hint** → use the **Hint Strategy** below instead of just calling the tool - **solution** → warn about spoilers before calling -- **reset** → confirm the user wants to lose their code before calling +- **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; it restores only the current activity — one `.qs` file for Q# courses, one notebook cell for notebook courses. Confirm the user wants to lose their code before calling. +- **reset unit** ("start this kata over", "clear my progress on this unit") → `reset-unit`; it restores every exercise **and** clears completion for the whole unit, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. - **switch course / list courses / course info** → use the **Courses** tools (`switch-course`, `list-courses`, `course-info`); call `show` after a switch - **"help with my code" / "debug"** → call `read-code`, then give personalized feedback - **Q# or QDK question** → if the answer isn't obvious from the current lesson context, **always** read the `/qdk-programming` skill before responding. @@ -134,3 +135,4 @@ Render the result, offer a brief reaction. Don't auto-call `next` — the user m - Don't reveal the solution without a spoiler warning - Don't invent state — call `get-state` if unsure - Don't dump raw state JSON to the user +- **Don't hand-edit the learner's workbook or `.qs` files to restore them.** To undo the learner's work, call `reset` or `reset-unit` — they copy the original content verbatim from the course source. Editing the file yourself risks writing code that was never part of the course. diff --git a/source/vscode/package.json b/source/vscode/package.json index ab4b90d50f9..5592db1001c 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -334,6 +334,10 @@ "command": "qsharp-vscode.learningResetUnit", "when": "false" }, + { + "command": "qsharp-vscode.learningOpenNotebook", + "when": "false" + }, { "command": "qsharp-vscode.learningShowActivity", "when": "false" @@ -761,6 +765,12 @@ "category": "QDK Learning", "icon": "$(discard)" }, + { + "command": "qsharp-vscode.learningOpenNotebook", + "title": "Open Course Notebook", + "category": "QDK Learning", + "icon": "$(notebook)" + }, { "command": "qsharp-vscode.learningShowActivity", "title": "Show Current Activity", @@ -1667,7 +1677,7 @@ ], "toolReferenceName": "qdkLearningReset", "displayName": "QDK Learning: Reset", - "modelDescription": "Reset the current exercise to its placeholder code and clear its completion. Destructive — requires confirmation. Only valid on exercises.", + "modelDescription": "Reset only the current activity to its original starter code and clear its completion. For python-notebook courses this restores just the current cell (an exercise cell or a plain code cell), leaving the learner's other cells intact. For Q# courses this restores the current exercise's .qs file and is only valid on exercises. Destructive — requires confirmation. To reset an entire unit, use qdk-learning-reset-unit instead.", "canBeReferencedInPrompt": true, "icon": "./resources/file-icon-light.svg", "inputSchema": { @@ -1677,6 +1687,30 @@ "additionalProperties": false } }, + { + "name": "qdk-learning-reset-unit", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningResetUnit", + "displayName": "QDK Learning: Reset Unit", + "modelDescription": "Reset an entire unit: restore every exercise and code example in the unit to its original starter code and clear completion for all of its activities. For python-notebook courses the unit's notebook is re-copied from the original. Destructive — all of the user's work in the unit is lost — requires confirmation. Defaults to the current unit; pass unitId from list-units or get-state to reset a different unit in the active course. To reset only the current exercise, use qdk-learning-reset instead.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "unitId": { + "type": "string", + "description": "ID of the unit to reset (e.g. 'superposition'). Defaults to the current unit." + } + }, + "required": [], + "additionalProperties": false + } + }, { "name": "qdk-create-notebook-venv", "tags": [ diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 6a63e9bdac0..1fd6b35164d 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -328,12 +328,90 @@ export class LearningTools { await this.ensureInitialized(); this.throwIfNotQSharpCourse(); return this.invoke(async () => { + // For notebook courses the cell the user means is the one selected in + // the editor, which is also what serializeState reports. Clicking a cell + // doesn't move the stored position, so sync it before resetting — + // otherwise the service would restore whichever cell the position + // happens to point at. + await this.syncNotebookPosition(); await this.service.resetExercise("chat"); await this.showActivity(); return { state: this.serializeState(false) }; // Q# only }); } + /** + * Confirmation for the reset tool. Returns `undefined` — no prompt — when + * the current activity has no code to restore, so the user isn't asked to + * approve a reset that will fail. + * + * **Must be free of side-effects** — only reads state and the editor. + */ + confirmReset(): vscode.PreparedToolInvocation | undefined { + const confirmation: vscode.PreparedToolInvocation = { + confirmationMessages: { + title: "Reset Exercise", + message: + "Reset the current exercise to the original placeholder? Your code will be lost.", + }, + }; + + // Uninitialized or unreadable state: let invoke() surface the problem. + if (!this.service.initialized) { + return confirmation; + } + + try { + if (!isNotebookCourse(this.service.getActiveCourseInfo())) { + // Q# courses can only reset exercises. + return this.service.getCurrentActivityType() === "exercise" + ? confirmation + : undefined; + } + + const cellId = this.selectedNotebookCellId(); + if (cellId === undefined) { + // No workbook cell selected — the reset targets the stored position. + return confirmation; + } + return this.service.isActivityCellId(cellId) ? confirmation : undefined; + } catch { + // Never block the tool on a confirmation-time failure. + return confirmation; + } + } + + /** + * Reset an entire unit, clearing completion for all of its activities. + * Defaults to the current unit. + */ + async resetUnit(input?: { + unitId?: string; + }): Promise<{ unitId: string; unitTitle: string } & StateSnapshot> { + await this.ensureInitialized(); + return this.invoke(async () => { + const { unitId, unitTitle } = await this.service.resetUnit( + { unitId: input?.unitId }, + "chat", + ); + + // Notebook courses close the workbook during a reset and don't use the + // lesson panel, so re-open the fresh copy. The open command resolves the + // notebook from the current position, so move there first — the reset + // unit isn't necessarily the one the learner was on. + if (isNotebookCourse(this.service.getActiveCourseInfo())) { + await this.service.goTo({ unitId }, "chat"); + await vscode.commands.executeCommand( + "qsharp-vscode.learningOpenNotebook", + ); + } else { + await this.showActivity(); + } + + return { unitId, unitTitle, state: this.serializeState() }; + }); + } + /** * Show the reference solution code(s). */ @@ -388,6 +466,47 @@ export class LearningTools { await vscode.commands.executeCommand("qsharp-vscode.learningShowActivity"); } + /** + * Move the stored position to the cell selected in the active notebook + * editor, so the reset targets the cell the learner is looking at. Throws + * when the selected cell isn't an activity. + */ + private async syncNotebookPosition(): Promise { + if (!isNotebookCourse(this.service.getActiveCourseInfo())) { + return; + } + const cellId = this.selectedNotebookCellId(); + if (cellId === undefined) { + return; + } + + if (!(await this.service.goToActivityByCellId(cellId, "chat"))) { + throw new CopilotToolError( + "The selected cell isn't an exercise or code cell, so there's nothing to reset. " + + "Select the cell to reset, or reset the whole unit instead.", + ); + } + } + + /** + * The id of the cell selected in the active notebook editor, when that + * editor is the current unit's workbook. `undefined` otherwise, in which + * case callers fall back to the stored position. + */ + private selectedNotebookCellId(): string | undefined { + const editor = vscode.window.activeNotebookEditor; + const selection = editor?.selections[0]; + if (!editor || !selection) { + return undefined; + } + const workbook = this.service.getCurrentCodeFileUri(); + if (!workbook || editor.notebook.uri.toString() !== workbook.toString()) { + return undefined; + } + const cellId = editor.notebook.cellAt(selection.start).metadata?.id; + return typeof cellId === "string" ? cellId : undefined; + } + private getCurrentFileUri(): vscode.Uri { const uri = this.service.getCurrentCodeFileUri(); if (!uri) { diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 06ea9bd1324..4eef1f11260 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -184,11 +184,17 @@ const toolDefinitions: { { name: "qdk-learning-reset", tool: async () => await learningTools!.resetExercise(), - confirm: () => ({ + confirm: () => learningTools!.confirmReset(), + }, + { + name: "qdk-learning-reset-unit", + tool: async (input) => await learningTools!.resetUnit(input), + confirm: (input: { unitId?: string }): vscode.PreparedToolInvocation => ({ confirmationMessages: { - title: "Reset Exercise", - message: - "Reset the current exercise to the original placeholder? Your code will be lost.", + title: "Reset Unit", + message: input?.unitId + ? `Reset unit "${input.unitId}" to its original state? All of your work in this unit will be lost.` + : "Reset the current unit to its original state? All of your work in this unit will be lost.", }, }), }, diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index a5fccad35ce..52b404092c1 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -66,7 +66,7 @@ export function registerLearningCommands( } const confirmed = await vscode.window.showWarningMessage( - "Reset this unit to the original notebook? Your current work will be lost.", + "Reset this unit to its original state? Your current work in this unit will be lost.", { modal: true }, "Reset", ); @@ -74,14 +74,36 @@ export function registerLearningCommands( return; } - await service.resetExercise(); - // The whole unit was reset, so the learner's old position no longer - // means anything — start them at the top of the fresh notebook. - await openCourseNotebook(service, { reveal: "top" }); + await service.resetUnit( + location ? { unitId: location.unitId } : undefined, + "tree", + ); + + // Notebook courses don't use the lesson panel, and the reset closed + // the workbook, so re-open the fresh copy instead. + if (isNotebookCourse(service.getActiveCourseInfo())) { + await openCourseNotebook(service, { reveal: "top" }); + } else { + await panelManager.show(); + } vscode.window.showInformationMessage("Unit has been reset."); }, ), + // Used by the chat tool to re-open a notebook it closed during a reset. + vscode.commands.registerCommand( + "qsharp-vscode.learningOpenNotebook", + async () => { + if ( + !service.initialized || + !isNotebookCourse(service.getActiveCourseInfo()) + ) { + return; + } + await openCourseNotebook(service, { reveal: "top" }); + }, + ), + // Progress tree commands vscode.commands.registerCommand( diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index fd4dde50f75..de2adc71c95 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -214,6 +214,53 @@ export function stripAuthoringCells( return `${JSON.stringify(notebook, undefined, 1)}\n`; } +/** + * Read a single cell's source text out of a notebook's JSON, matched by its + * stable nbformat cell ID. + */ +export function findCellSource( + text: string, + cellId: string, + unitLabel: string, +): string | undefined { + const cells = readCells(text, unitLabel); + const cell = cells?.find((c) => cellIdOf(c) === cellId); + return cell ? cellSource(cell) : undefined; +} + +/** + * Return the notebook JSON with one cell's source replaced, matched by its + * stable nbformat cell ID. The replaced cell's outputs are cleared, since + * restored code has not been run. + */ +export function replaceCellSource( + text: string, + cellId: string, + newSource: string, + unitLabel: string, +): string | undefined { + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { + return undefined; + } + + const cell = notebook.cells.find((c) => cellIdOf(c) === cellId); + if (!cell) { + return undefined; + } + + cell.source = newSource; + // A restored cell has never been run, so drop any stale execution state. + if (cellKind(cell) === "code") { + (cell as { outputs?: unknown }).outputs = []; + (cell as { execution_count?: unknown }).execution_count = null; + } + + // Match the ipynb serializer's formatting so the file stays diff-stable + // once VS Code starts saving it: one space of indent, trailing newline. + return `${JSON.stringify(notebook, undefined, 1)}\n`; +} + // ─── Cell readers ─── /** diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts index db87f3164b3..033d3c25154 100644 --- a/source/vscode/src/learning/python/materialization.ts +++ b/source/vscode/src/learning/python/materialization.ts @@ -5,7 +5,11 @@ import { log } from "qsharp-lang"; import * as vscode from "vscode"; import { sourceNotebookUri, workbookUri } from "../courseLayout.js"; import { ensureParentDir, uriExists } from "../fsUtils.js"; -import { stripAuthoringCells } from "../notebookExercises.js"; +import { + findCellSource, + replaceCellSource, + stripAuthoringCells, +} from "../notebookExercises.js"; import type { NotebookCatalogCourse, NotebookCatalogUnit } from "../types.js"; /** @@ -39,6 +43,99 @@ export async function rematerializeUnitWorkbook( ); } +/** + * Restore a single cell in a unit's working copy to its authored state, + * leaving the learner's other cells untouched. Uses the notebook API when the + * workbook is open, since an open notebook doesn't reliably pick up external + * writes. Returns `false` when the cell can't be found. + */ +export async function restoreUnitWorkbookCell( + unit: NotebookCatalogUnit, + cellId: string, +): Promise { + try { + const srcText = new TextDecoder().decode( + await vscode.workspace.fs.readFile(sourceNotebookUri(unit)), + ); + const original = findCellSource(srcText, cellId, unit.id); + if (original === undefined) { + log.warn( + `Cell ${cellId} not found in the authored notebook for unit "${unit.id}".`, + ); + return false; + } + + const dest = workbookUri(unit); + const open = vscode.workspace.notebookDocuments.find( + (n) => n.uri.toString() === dest.toString(), + ); + if (open && (await replaceOpenCell(open, cellId, original))) { + return true; + } + + const destText = new TextDecoder().decode( + await vscode.workspace.fs.readFile(dest), + ); + const updated = replaceCellSource(destText, cellId, original, unit.id); + if (updated === undefined) { + log.warn( + `Cell ${cellId} not found in the workbook for unit "${unit.id}".`, + ); + return false; + } + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(updated), + ); + return true; + } catch (e) { + log.warn( + `Failed to restore cell ${cellId} in unit "${unit.id}": ${String(e)}`, + ); + return false; + } +} + +/** + * Replace one cell of an open notebook, preserving its id and tags while + * dropping outputs and execution state. Returns `false` if the cell isn't + * present or the edit is rejected, leaving the caller to fall back to disk. + */ +async function replaceOpenCell( + notebook: vscode.NotebookDocument, + cellId: string, + source: string, +): Promise { + const index = notebook.getCells().findIndex((c) => c.metadata?.id === cellId); + if (index < 0) { + return false; + } + + const existing = notebook.cellAt(index); + const data = new vscode.NotebookCellData( + existing.kind, + source, + existing.document.languageId, + ); + // Keep the metadata so the stable cell id and its tags survive the replace. + data.metadata = existing.metadata; + data.outputs = []; + data.executionSummary = undefined; + + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [ + vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(index, index + 1), + [data], + ), + ]); + if (!(await vscode.workspace.applyEdit(edit))) { + return false; + } + await notebook.save(); + return true; +} + /** * Write a unit's working copy: the authored notebook minus its author-only * cells (hints, solutions, explanations). diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 5540ec2ad14..f020db3b488 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -14,6 +14,7 @@ import { promptInstallPythonExtensions } from "./python/extensionUtils.js"; import { materializeCourseWorkbooks, rematerializeUnitWorkbook, + restoreUnitWorkbookCell, } from "./python/materialization.js"; import { KATAS_COURSE_ID, @@ -672,6 +673,11 @@ export class LearningService { }; } + /** The type of the activity at the current position. */ + getCurrentActivityType(): CatalogActivity["type"] { + return this.findCurrentActivity().activity.type; + } + /** * The notebook cell ID backing the current activity — the inverse of * {@link goToActivityByCellId}. `undefined` when the course isn't a @@ -913,11 +919,16 @@ export class LearningService { getExerciseFileUri(): vscode.Uri { const exercise = this.resolveExercise(); + return this.exerciseFileUri(this.position.unitId, exercise.id); + } + + /** URI of the user's working copy of a specific exercise. */ + private exerciseFileUri(unitId: string, exerciseId: string): vscode.Uri { return vscode.Uri.joinPath( this.requireWorkspace().learningContentRoot, "exercises", - this.position.unitId, - `${exercise.id}.qs`, + unitId, + `${exerciseId}.qs`, ); } @@ -926,11 +937,16 @@ export class LearningService { if (activity.type !== "lesson" || !activity.example) { throw new Error("Current activity is not an example"); } + return this.exampleFileUri(unit.id, activity.example.id); + } + + /** URI of the user's working copy of a specific example. */ + private exampleFileUri(unitId: string, exampleId: string): vscode.Uri { return vscode.Uri.joinPath( this.requireWorkspace().learningContentRoot, "examples", - unit.id, - `${activity.example.id}.qs`, + unitId, + `${exampleId}.qs`, ); } @@ -979,22 +995,25 @@ export class LearningService { } /** - * Reset the current exercise/unit to its original state and clear - * completion status. + * Reset the current exercise to its original state and clear its + * completion status. For python-notebook courses this restores just the + * current cell. */ async resetExercise(source?: TelemetrySource): Promise { - // Python-notebook courses: close the notebook, re-copy the entire unit - // from source, and clear completion. const course = this.activeCourse; if (isNotebookCourse(course)) { + const cellId = this.getCurrentExerciseCellId(); + if (!cellId) { + throw new Error("The current activity has no code cell to reset."); + } const unit = this.findCourseUnit(course, this.position.unitId); - // Close any open notebook tabs for this unit. - await this.closeNotebookTab(workbookUri(unit)); - // Re-materialize the unit from source. - await rematerializeUnitWorkbook(unit); - // Clear completion for every activity in the unit, not just the - // current one, since the whole unit was re-materialized. - this.markUnitIncomplete(course.id, unit); + const restored = await restoreUnitWorkbookCell(unit, cellId); + if (!restored) { + throw new Error( + "Could not restore this exercise cell. Reset the whole unit instead.", + ); + } + this.markIncomplete(this.requireWorkspace().progressData.position); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { @@ -1021,6 +1040,65 @@ export class LearningService { } } + /** + * Reset an entire unit: restore every learner-editable file in it and clear + * completion for all of its activities. Defaults to the current unit, which + * must belong to the active course. + */ + async resetUnit( + input?: { unitId?: string }, + source?: TelemetrySource, + ): Promise<{ unitId: string; unitTitle: string }> { + const course = this.activeCourse; + const unitId = input?.unitId ?? this.position.unitId; + + if (isNotebookCourse(course)) { + const unit = this.findCourseUnit(course, unitId); + // Close any open notebook tabs for this unit before overwriting it. + await this.closeNotebookTab(workbookUri(unit)); + await rematerializeUnitWorkbook(unit); + return this.finishUnitReset(course.id, unit, source); + } + + const unit = this.findCourseUnit(course, unitId); + // Paths use the catalog's own ids, never the caller's. + for (const activity of unit.activities) { + let uri: vscode.Uri; + let code: string; + if (activity.type === "exercise") { + uri = this.exerciseFileUri(unit.id, activity.id); + code = activity.placeholderCode; + } else if (activity.type === "lesson" && activity.example) { + uri = this.exampleFileUri(unit.id, activity.example.id); + code = activity.example.code; + } else { + continue; + } + // Save any unsaved edits first so the editor is clean, then overwrite + // the file on disk. The editor will pick up the change automatically + // because it's no longer dirty. + await this.saveOpenDocument(uri); + await ensureParentDir(uri); + await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(code)); + } + return this.finishUnitReset(course.id, unit, source); + } + + /** Clear the unit's completions and persist, shared by both reset paths. */ + private async finishUnitReset( + courseId: string, + unit: CatalogUnit, + source?: TelemetrySource, + ): Promise<{ unitId: string; unitTitle: string }> { + this.markUnitIncomplete(courseId, unit); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + if (source) { + this.sendActivityActionTelemetry("reset-unit", source); + } + return { unitId: unit.id, unitTitle: unit.title }; + } + async run( shots: number = 1, source?: TelemetrySource, @@ -1167,7 +1245,14 @@ export class LearningService { } sendActivityActionTelemetry( - action: "navigate" | "run" | "check" | "hint" | "solution" | "reset", + action: + | "navigate" + | "run" + | "check" + | "hint" + | "solution" + | "reset" + | "reset-unit", source: TelemetrySource, ): void { const activityType = this.findCurrentActivity().activity.type; diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index b66928debb9..5c61d96b67c 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -343,7 +343,14 @@ type EventTypes = { }; [EventType.LearningActivityAction]: { properties: { - action: "navigate" | "run" | "check" | "hint" | "solution" | "reset"; + action: + | "navigate" + | "run" + | "check" + | "hint" + | "solution" + | "reset" + | "reset-unit"; activityType: "lesson" | "exercise" | "code-cell"; source: "panel" | "chat" | "tree" | "notebook"; }; From ea8912b98b76f0ec3619aa49386f8cee853ced8d Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 1 Sep 2026 17:57:11 -0700 Subject: [PATCH 02/10] Enable notebook per cell reset on latest main --- source/vscode/src/gh-copilot/learningTools.ts | 43 ++++------------ source/vscode/src/learning/service.ts | 50 ++++++++++++++----- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 1fd6b35164d..d86ea961aa4 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -6,6 +6,7 @@ import { LearningService, LEARNING_WORKSPACE_FOLDER, detectLearningWorkspace, + isNotebookCourse, resolveNewWorkspaceRoot, type CourseDescriptor, type CurrentActivity, @@ -326,17 +327,13 @@ export class LearningTools { */ async resetExercise(): Promise { await this.ensureInitialized(); - this.throwIfNotQSharpCourse(); return this.invoke(async () => { - // For notebook courses the cell the user means is the one selected in - // the editor, which is also what serializeState reports. Clicking a cell - // doesn't move the stored position, so sync it before resetting — - // otherwise the service would restore whichever cell the position - // happens to point at. - await this.syncNotebookPosition(); - await this.service.resetExercise("chat"); + // Resolve the target from the editor — the selected cell for notebook + // courses — rather than the stored position, matching hint/solution. + const state = this.serializeState(true); + await this.service.resetExerciseAt(state.position.location, "chat"); await this.showActivity(); - return { state: this.serializeState(false) }; // Q# only + return { state: this.serializeState(true) }; }); } @@ -350,9 +347,9 @@ export class LearningTools { confirmReset(): vscode.PreparedToolInvocation | undefined { const confirmation: vscode.PreparedToolInvocation = { confirmationMessages: { - title: "Reset Exercise", + title: "Reset Activity", message: - "Reset the current exercise to the original placeholder? Your code will be lost.", + "Reset the current activity to its starter code? Your code will be lost.", }, }; @@ -408,7 +405,7 @@ export class LearningTools { await this.showActivity(); } - return { unitId, unitTitle, state: this.serializeState() }; + return { unitId, unitTitle, state: this.serializeState(false) }; }); } @@ -466,28 +463,6 @@ export class LearningTools { await vscode.commands.executeCommand("qsharp-vscode.learningShowActivity"); } - /** - * Move the stored position to the cell selected in the active notebook - * editor, so the reset targets the cell the learner is looking at. Throws - * when the selected cell isn't an activity. - */ - private async syncNotebookPosition(): Promise { - if (!isNotebookCourse(this.service.getActiveCourseInfo())) { - return; - } - const cellId = this.selectedNotebookCellId(); - if (cellId === undefined) { - return; - } - - if (!(await this.service.goToActivityByCellId(cellId, "chat"))) { - throw new CopilotToolError( - "The selected cell isn't an exercise or code cell, so there's nothing to reset. " + - "Select the cell to reset, or reset the whole unit instead.", - ); - } - } - /** * The id of the cell selected in the active notebook editor, when that * editor is the current unit's workbook. `undefined` otherwise, in which diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index f020db3b488..16becf1a6d1 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1000,30 +1000,53 @@ export class LearningService { * current cell. */ async resetExercise(source?: TelemetrySource): Promise { - const course = this.activeCourse; + await this.resetExerciseAt( + this.requireWorkspace().progressData.position, + source, + ); + } + + /** + * Reset the exercise at {@link location} and clear its completion — one + * `.qs` file for Q# courses, or one cell for notebook courses. Lets + * editor-aware callers target the selected cell rather than the stored + * position, mirroring {@link getHintContext} and {@link getAllSolutions}. + */ + async resetExerciseAt( + location: ActivityLocation, + source?: TelemetrySource, + ): Promise { + const ws = this.requireWorkspace(); + const course = this.requireCourse(ws, location.courseId); + if (isNotebookCourse(course)) { - const cellId = this.getCurrentExerciseCellId(); - if (!cellId) { + const unit = this.findCourseUnit(course, location.unitId); + const activity = unit.activities.find( + (a) => a.id === location.activityId, + ); + if ( + !activity || + (activity.type !== "exercise" && activity.type !== "code-cell") + ) { throw new Error("The current activity has no code cell to reset."); } - const unit = this.findCourseUnit(course, this.position.unitId); - const restored = await restoreUnitWorkbookCell(unit, cellId); + const restored = await restoreUnitWorkbookCell(unit, location.activityId); if (!restored) { throw new Error( - "Could not restore this exercise cell. Reset the whole unit instead.", + "Could not restore this cell. Reset the whole unit instead.", ); } - this.markIncomplete(this.requireWorkspace().progressData.position); + this.markIncomplete(location); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { - this.sendActivityActionTelemetry("reset", source); + this.sendActivityActionTelemetry("reset", source, activity.type); } return; } - const exercise = this.resolveExercise(); - const uri = this.getExerciseFileUri(); + const exercise = this.resolveExerciseAt(location); + const uri = this.exerciseFileUri(location.unitId, exercise.id); // Save any unsaved edits first so the editor is clean, then overwrite // the file on disk. The editor will pick up the change automatically // because it's no longer dirty. @@ -1032,11 +1055,11 @@ export class LearningService { uri, new TextEncoder().encode(exercise.placeholderCode), ); - this.markIncomplete(this.requireWorkspace().progressData.position); + this.markIncomplete(location); await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { - this.sendActivityActionTelemetry("reset", source); + this.sendActivityActionTelemetry("reset", source, exercise.type); } } @@ -1254,8 +1277,9 @@ export class LearningService { | "reset" | "reset-unit", source: TelemetrySource, + activityType: CatalogActivity["type"] = this.findCurrentActivity().activity + .type, ): void { - const activityType = this.findCurrentActivity().activity.type; sendTelemetryEvent( EventType.LearningActivityAction, { action, activityType, source }, From 528285f3795d22e61d3072adae0e712cd69d3980 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Mon, 14 Sep 2026 01:32:06 -0700 Subject: [PATCH 03/10] Stop reporting failed notebook resets as successful --- source/vscode/ai/qdk-learning.agent.md | 2 +- source/vscode/src/gh-copilot/learningTools.ts | 27 +++++++++++++ .../src/learning/python/materialization.ts | 40 ++++++++++--------- source/vscode/src/learning/service.ts | 30 +++++++++----- 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index ff4e3660642..3ca6a721f37 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -100,7 +100,7 @@ Call `get-state` first. If the user is asking to navigate, run, check, reset, et - **hint** → use the **Hint Strategy** below instead of just calling the tool - **solution** → warn about spoilers before calling -- **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; it restores only the current activity — one `.qs` file for Q# courses, one notebook cell for notebook courses. Confirm the user wants to lose their code before calling. +- **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; restores only the current activity — one `.qs` file, or one notebook cell. Confirm the user wants to lose their code before calling. - **reset unit** ("start this kata over", "clear my progress on this unit") → `reset-unit`; it restores every exercise **and** clears completion for the whole unit, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. - **switch course / list courses / course info** → use the **Courses** tools (`switch-course`, `list-courses`, `course-info`); call `show` after a switch - **"help with my code" / "debug"** → call `read-code`, then give personalized feedback diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index d86ea961aa4..4f214ca0354 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -330,6 +330,15 @@ export class LearningTools { return this.invoke(async () => { // Resolve the target from the editor — the selected cell for notebook // courses — rather than the stored position, matching hint/solution. + // A destructive reset must never silently act on a different cell, so + // if a workbook is focused but its selected cell can't be identified, + // fail loudly instead of falling back to the stored position. + if (this.notebookSelectionUnidentified()) { + throw new CopilotToolError( + "I couldn't tell which cell is selected — it has no stable id yet. " + + "Click into the exercise cell you want to reset and try again, or reset the whole unit.", + ); + } const state = this.serializeState(true); await this.service.resetExerciseAt(state.position.location, "chat"); await this.showActivity(); @@ -337,6 +346,24 @@ export class LearningTools { }); } + /** + * True when the active editor is the current unit's workbook but the + * selected cell has no stable id. In that case {@link serializeState} would + * silently fall back to the stored position, which is unsafe for reset. + */ + private notebookSelectionUnidentified(): boolean { + if (!isNotebookCourse(this.service.getActiveCourseInfo())) { + return false; + } + const editor = vscode.window.activeNotebookEditor; + const workbook = this.service.getCurrentCodeFileUri(); + const onWorkbook = + !!editor && + !!workbook && + editor.notebook.uri.toString() === workbook.toString(); + return onWorkbook && this.selectedNotebookCellId() === undefined; + } + /** * Confirmation for the reset tool. Returns `undefined` — no prompt — when * the current activity has no code to restore, so the user isn't asked to diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts index 033d3c25154..1efdd29f70a 100644 --- a/source/vscode/src/learning/python/materialization.ts +++ b/source/vscode/src/learning/python/materialization.ts @@ -31,12 +31,13 @@ export async function materializeCourseWorkbooks( /** * Re-materialize a single unit: overwrite its `*.workbook.ipynb` - * with a fresh copy derived from the authored notebook. + * with a fresh copy derived from the authored notebook. Returns `false` if + * the workbook could not be written. */ export async function rematerializeUnitWorkbook( unit: NotebookCatalogUnit, -): Promise { - await materializeNotebook( +): Promise { + return materializeNotebook( sourceNotebookUri(unit), workbookUri(unit), unit.id, @@ -45,9 +46,10 @@ export async function rematerializeUnitWorkbook( /** * Restore a single cell in a unit's working copy to its authored state, - * leaving the learner's other cells untouched. Uses the notebook API when the - * workbook is open, since an open notebook doesn't reliably pick up external - * writes. Returns `false` when the cell can't be found. + * leaving the learner's other cells untouched. When the workbook is open the + * in-editor edit is authoritative — an open notebook won't reliably observe an + * external file write, and a later save would clobber it. Returns `false` when + * the cell can't be found or the restore fails. */ export async function restoreUnitWorkbookCell( unit: NotebookCatalogUnit, @@ -69,8 +71,8 @@ export async function restoreUnitWorkbookCell( const open = vscode.workspace.notebookDocuments.find( (n) => n.uri.toString() === dest.toString(), ); - if (open && (await replaceOpenCell(open, cellId, original))) { - return true; + if (open) { + return replaceOpenCell(open, cellId, original); } const destText = new TextDecoder().decode( @@ -99,7 +101,7 @@ export async function restoreUnitWorkbookCell( /** * Replace one cell of an open notebook, preserving its id and tags while * dropping outputs and execution state. Returns `false` if the cell isn't - * present or the edit is rejected, leaving the caller to fall back to disk. + * present, the edit is rejected, or the save doesn't complete. */ async function replaceOpenCell( notebook: vscode.NotebookDocument, @@ -132,13 +134,13 @@ async function replaceOpenCell( if (!(await vscode.workspace.applyEdit(edit))) { return false; } - await notebook.save(); - return true; + return notebook.save(); } /** * Write a unit's working copy: the authored notebook minus its author-only - * cells (hints, solutions, explanations). + * cells (hints, solutions, explanations). Returns `false` if the copy could + * not be written. * * If the notebook can't be parsed we fall back to copying it verbatim, so a * malformed notebook still leaves the learner with something to work in @@ -148,7 +150,7 @@ async function materializeNotebook( src: vscode.Uri, dest: vscode.Uri, unitId: string, -): Promise { +): Promise { try { await ensureParentDir(dest); const text = new TextDecoder().decode( @@ -157,15 +159,17 @@ async function materializeNotebook( const stripped = stripAuthoringCells(text, unitId); if (stripped === undefined) { await vscode.workspace.fs.copy(src, dest, { overwrite: true }); - return; + } else { + await vscode.workspace.fs.writeFile( + dest, + new TextEncoder().encode(stripped), + ); } - await vscode.workspace.fs.writeFile( - dest, - new TextEncoder().encode(stripped), - ); + return true; } catch (e) { log.warn( `Failed to materialize ${src.fsPath} → ${dest.fsPath}: ${String(e)}`, ); + return false; } } diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 16becf1a6d1..3a3ae2711f3 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1077,9 +1077,17 @@ export class LearningService { if (isNotebookCourse(course)) { const unit = this.findCourseUnit(course, unitId); - // Close any open notebook tabs for this unit before overwriting it. - await this.closeNotebookTab(workbookUri(unit)); - await rematerializeUnitWorkbook(unit); + // Close any open notebook tab first. If the learner cancels a + // save-on-close prompt, abort — otherwise the still-open editor could + // later save stale content back over the reset. + if (!(await this.closeNotebookTab(workbookUri(unit)))) { + throw new Error( + "Couldn't close the open notebook. Save or close it, then try again.", + ); + } + if (!(await rematerializeUnitWorkbook(unit))) { + throw new Error("Couldn't restore the unit's notebook."); + } return this.finishUnitReset(course.id, unit, source); } @@ -1598,15 +1606,18 @@ export class LearningService { /** * Close every open text or notebook tab whose URI matches {@link predicate}. * Tabs backed by any other input kind (diff views, webviews, terminals) are - * skipped, since they have no single URI to match against. + * skipped, since they have no single URI to match against. Returns `false` + * if a matching tab could not be closed (e.g. the user cancelled a + * save-on-close prompt). */ private async closeTabs( predicate: (uri: vscode.Uri, tab: vscode.Tab) => boolean, - ): Promise { + ): Promise { const matches = this.findTabs(predicate); - if (matches.length > 0) { - await vscode.window.tabGroups.close(matches); + if (matches.length === 0) { + return true; } + return vscode.window.tabGroups.close(matches); } /** @@ -2128,10 +2139,11 @@ export class LearningService { /** * Close any open editor tabs whose URI matches the given notebook URI. + * Returns `false` if a matching tab could not be closed. */ - private async closeNotebookTab(uri: vscode.Uri): Promise { + private async closeNotebookTab(uri: vscode.Uri): Promise { const uriStr = uri.toString(); - await this.closeTabs( + return this.closeTabs( (tabUri, tab) => tab.input instanceof vscode.TabInputNotebook && tabUri.toString() === uriStr, From 2c23117bd815d80364c95e7bf888a7623a25bae7 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Mon, 14 Sep 2026 16:10:55 -0700 Subject: [PATCH 04/10] Report reset save failures instead of silently succeeding --- .../src/learning/python/materialization.ts | 41 +++++++++++++++---- source/vscode/src/learning/service.ts | 38 ++++++++++++----- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts index 1efdd29f70a..411d4353e24 100644 --- a/source/vscode/src/learning/python/materialization.ts +++ b/source/vscode/src/learning/python/materialization.ts @@ -59,8 +59,8 @@ export async function restoreUnitWorkbookCell( const srcText = new TextDecoder().decode( await vscode.workspace.fs.readFile(sourceNotebookUri(unit)), ); - const original = findCellSource(srcText, cellId, unit.id); - if (original === undefined) { + const authoredSource = findCellSource(srcText, cellId, unit.id); + if (authoredSource === undefined) { log.warn( `Cell ${cellId} not found in the authored notebook for unit "${unit.id}".`, ); @@ -72,13 +72,18 @@ export async function restoreUnitWorkbookCell( (n) => n.uri.toString() === dest.toString(), ); if (open) { - return replaceOpenCell(open, cellId, original); + return replaceOpenCell(open, cellId, authoredSource); } const destText = new TextDecoder().decode( await vscode.workspace.fs.readFile(dest), ); - const updated = replaceCellSource(destText, cellId, original, unit.id); + const updated = replaceCellSource( + destText, + cellId, + authoredSource, + unit.id, + ); if (updated === undefined) { log.warn( `Cell ${cellId} not found in the workbook for unit "${unit.id}".`, @@ -99,9 +104,14 @@ export async function restoreUnitWorkbookCell( } /** - * Replace one cell of an open notebook, preserving its id and tags while - * dropping outputs and execution state. Returns `false` if the cell isn't - * present, the edit is rejected, or the save doesn't complete. + * Replace one cell of an open notebook with its authored source, preserving + * the cell id and tags while dropping outputs and execution state. The + * in-editor edit is the reset: it takes effect the moment the cell shows the + * authored source, and the notebook is then saved to disk best-effort. Returns + * `false` only when the cell is missing or the edit is rejected. A save that + * can't complete leaves the reset cell unsaved in the editor — like any other + * pending edit — instead of undoing the reset, so we never leave the cell + * showing placeholder code and then report the reset as a failure. */ async function replaceOpenCell( notebook: vscode.NotebookDocument, @@ -134,7 +144,22 @@ async function replaceOpenCell( if (!(await vscode.workspace.applyEdit(edit))) { return false; } - return notebook.save(); + + // Persist the reset best-effort. The edit already updated the editor, so a + // save that doesn't land just leaves the cell unsaved, not un-reset; log it + // for diagnostics but still report the cell as reset. + try { + if (!(await notebook.save())) { + log.warn( + `Reset cell ${cellId} in the editor, but saving the workbook to disk didn't complete.`, + ); + } + } catch (e) { + log.warn( + `Reset cell ${cellId} in the editor, but saving the workbook to disk failed: ${String(e)}`, + ); + } + return true; } /** diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 3a3ae2711f3..36f3fa076de 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -960,14 +960,21 @@ export class LearningService { return new TextDecoder().decode(bytes); } - /** Save the document to disk if it's open and has unsaved edits. */ - private async saveOpenDocument(uri: vscode.Uri): Promise { + /** + * Save the document to disk if it's open and has unsaved edits. Returns + * `false` if the document is still dirty afterwards (the save didn't + * complete), so callers can avoid overwriting a file the editor would + * clobber back. + */ + private async saveOpenDocument(uri: vscode.Uri): Promise { const doc = vscode.workspace.textDocuments.find( (d) => d.uri.toString() === uri.toString(), ); - if (doc?.isDirty) { - await doc.save(); + if (!doc?.isDirty) { + return true; } + await doc.save(); + return !doc.isDirty; } async markExampleRun(): Promise { @@ -1047,10 +1054,15 @@ export class LearningService { const exercise = this.resolveExerciseAt(location); const uri = this.exerciseFileUri(location.unitId, exercise.id); - // Save any unsaved edits first so the editor is clean, then overwrite - // the file on disk. The editor will pick up the change automatically - // because it's no longer dirty. - await this.saveOpenDocument(uri); + // Save any unsaved edits first so the editor is clean, then overwrite the + // file on disk, the editor picks up the change because it's no longer + // dirty. If the save didn't take, abort: a still-dirty editor would save + // the user's old code back over the placeholder. + if (!(await this.saveOpenDocument(uri))) { + throw new Error( + "Couldn't save your open file. Save or close it, then try again.", + ); + } await vscode.workspace.fs.writeFile( uri, new TextEncoder().encode(exercise.placeholderCode), @@ -1106,9 +1118,13 @@ export class LearningService { continue; } // Save any unsaved edits first so the editor is clean, then overwrite - // the file on disk. The editor will pick up the change automatically - // because it's no longer dirty. - await this.saveOpenDocument(uri); + // the file on disk. Abort the whole unit reset if a save didn't take, + // rather than clearing progress on a file we couldn't actually reset. + if (!(await this.saveOpenDocument(uri))) { + throw new Error( + "Couldn't save an open file in this unit. Save or close it, then try again.", + ); + } await ensureParentDir(uri); await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(code)); } From c66ba6fe6813a19e616aed6f84d6909ab7dd287a Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 15 Sep 2026 13:26:08 -0700 Subject: [PATCH 05/10] Restrict unit reset to notebook courses Q# exercises are already separate files with their own reset, so a whole-unit reset added complexity for little gain. Unit reset is now notebook-only and recopies the notebook from the original; Q# courses reset exercises one at a time. --- source/vscode/ai/qdk-learning.agent.md | 4 +- source/vscode/package.json | 4 +- source/vscode/src/gh-copilot/learningTools.ts | 15 ++-- source/vscode/src/learning/service.ts | 68 +++++++------------ 4 files changed, 34 insertions(+), 57 deletions(-) diff --git a/source/vscode/ai/qdk-learning.agent.md b/source/vscode/ai/qdk-learning.agent.md index 3ca6a721f37..0c26a2ec402 100644 --- a/source/vscode/ai/qdk-learning.agent.md +++ b/source/vscode/ai/qdk-learning.agent.md @@ -101,7 +101,7 @@ Call `get-state` first. If the user is asking to navigate, run, check, reset, et - **hint** → use the **Hint Strategy** below instead of just calling the tool - **solution** → warn about spoilers before calling - **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; restores only the current activity — one `.qs` file, or one notebook cell. Confirm the user wants to lose their code before calling. -- **reset unit** ("start this kata over", "clear my progress on this unit") → `reset-unit`; it restores every exercise **and** clears completion for the whole unit, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. +- **reset unit** ("start this notebook over", "clear my progress on this unit") → `reset-unit`; **python-notebook courses only** — it re-copies the whole notebook from the original and clears the unit's completion, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. Q# courses have no unit reset; reset their exercises one at a time with `reset`. - **switch course / list courses / course info** → use the **Courses** tools (`switch-course`, `list-courses`, `course-info`); call `show` after a switch - **"help with my code" / "debug"** → call `read-code`, then give personalized feedback - **Q# or QDK question** → if the answer isn't obvious from the current lesson context, **always** read the `/qdk-programming` skill before responding. @@ -135,4 +135,4 @@ Render the result, offer a brief reaction. Don't auto-call `next` — the user m - Don't reveal the solution without a spoiler warning - Don't invent state — call `get-state` if unsure - Don't dump raw state JSON to the user -- **Don't hand-edit the learner's workbook or `.qs` files to restore them.** To undo the learner's work, call `reset` or `reset-unit` — they copy the original content verbatim from the course source. Editing the file yourself risks writing code that was never part of the course. +- **Don't hand-edit the learner's workbook or `.qs` files to restore them.** To undo the learner's work, call `reset` (or `reset-unit` for a whole notebook) — they copy the original content verbatim from the course source. Editing the file yourself risks writing code that was never part of the course. diff --git a/source/vscode/package.json b/source/vscode/package.json index 5592db1001c..c7b7c65cf4a 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -1677,7 +1677,7 @@ ], "toolReferenceName": "qdkLearningReset", "displayName": "QDK Learning: Reset", - "modelDescription": "Reset only the current activity to its original starter code and clear its completion. For python-notebook courses this restores just the current cell (an exercise cell or a plain code cell), leaving the learner's other cells intact. For Q# courses this restores the current exercise's .qs file and is only valid on exercises. Destructive — requires confirmation. To reset an entire unit, use qdk-learning-reset-unit instead.", + "modelDescription": "Reset only the current activity to its original starter code and clear its completion. For python-notebook courses this restores just the current cell (an exercise cell or a plain code cell), leaving the learner's other cells intact; to reset the whole notebook use qdk-learning-reset-unit. For Q# courses this restores the current exercise's .qs file and is only valid on exercises. Destructive — requires confirmation.", "canBeReferencedInPrompt": true, "icon": "./resources/file-icon-light.svg", "inputSchema": { @@ -1696,7 +1696,7 @@ ], "toolReferenceName": "qdkLearningResetUnit", "displayName": "QDK Learning: Reset Unit", - "modelDescription": "Reset an entire unit: restore every exercise and code example in the unit to its original starter code and clear completion for all of its activities. For python-notebook courses the unit's notebook is re-copied from the original. Destructive — all of the user's work in the unit is lost — requires confirmation. Defaults to the current unit; pass unitId from list-units or get-state to reset a different unit in the active course. To reset only the current exercise, use qdk-learning-reset instead.", + "modelDescription": "Reset an entire unit's notebook: re-copy it from the course original, discarding all of the learner's edits across every cell and clearing completion for the unit. Python-notebook courses only — Q# courses have no unit reset, so reset their exercises one at a time with qdk-learning-reset. Destructive — all of the user's work in the unit is lost — requires confirmation. Defaults to the current unit; pass unitId from list-units or get-state to reset a different unit in the active course.", "canBeReferencedInPrompt": true, "icon": "./resources/file-icon-light.svg", "inputSchema": { diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 4f214ca0354..0d51c726322 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -414,23 +414,20 @@ export class LearningTools { }): Promise<{ unitId: string; unitTitle: string } & StateSnapshot> { await this.ensureInitialized(); return this.invoke(async () => { + // Unit reset is notebook-only; the service rejects Q# courses. const { unitId, unitTitle } = await this.service.resetUnit( { unitId: input?.unitId }, "chat", ); - // Notebook courses close the workbook during a reset and don't use the + // The reset closed the workbook and notebook courses don't use the // lesson panel, so re-open the fresh copy. The open command resolves the // notebook from the current position, so move there first — the reset // unit isn't necessarily the one the learner was on. - if (isNotebookCourse(this.service.getActiveCourseInfo())) { - await this.service.goTo({ unitId }, "chat"); - await vscode.commands.executeCommand( - "qsharp-vscode.learningOpenNotebook", - ); - } else { - await this.showActivity(); - } + await this.service.goTo({ unitId }, "chat"); + await vscode.commands.executeCommand( + "qsharp-vscode.learningOpenNotebook", + ); return { unitId, unitTitle, state: this.serializeState(false) }; }); diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 36f3fa076de..b51d0849e3d 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -955,6 +955,10 @@ export class LearningService { if (!uri) { throw new Error("Current activity has no associated code file."); } + // TODO: saveOpenDocument now reports whether the save landed. If it + // didn't, this reads stale code off disk and checks the wrong content. + // Read the open document's in-memory text directly rather than saving + // then re-reading. await this.saveOpenDocument(uri); const bytes = await vscode.workspace.fs.readFile(uri); return new TextDecoder().decode(bytes); @@ -1076,62 +1080,38 @@ export class LearningService { } /** - * Reset an entire unit: restore every learner-editable file in it and clear - * completion for all of its activities. Defaults to the current unit, which - * must belong to the active course. + * Reset an entire unit: recopy its notebook from the course original and + * clear completion for all of its activities. Notebook courses only — a Q# + * unit is a folder of separate `.qs` files that are reset individually via + * {@link resetExercise}. Defaults to the current unit. */ async resetUnit( input?: { unitId?: string }, source?: TelemetrySource, ): Promise<{ unitId: string; unitTitle: string }> { const course = this.activeCourse; - const unitId = input?.unitId ?? this.position.unitId; - - if (isNotebookCourse(course)) { - const unit = this.findCourseUnit(course, unitId); - // Close any open notebook tab first. If the learner cancels a - // save-on-close prompt, abort — otherwise the still-open editor could - // later save stale content back over the reset. - if (!(await this.closeNotebookTab(workbookUri(unit)))) { - throw new Error( - "Couldn't close the open notebook. Save or close it, then try again.", - ); - } - if (!(await rematerializeUnitWorkbook(unit))) { - throw new Error("Couldn't restore the unit's notebook."); - } - return this.finishUnitReset(course.id, unit, source); + if (!isNotebookCourse(course)) { + throw new Error( + "Resetting a whole unit is only supported for notebook courses. Reset Q# exercises one at a time instead.", + ); } - + const unitId = input?.unitId ?? this.position.unitId; const unit = this.findCourseUnit(course, unitId); - // Paths use the catalog's own ids, never the caller's. - for (const activity of unit.activities) { - let uri: vscode.Uri; - let code: string; - if (activity.type === "exercise") { - uri = this.exerciseFileUri(unit.id, activity.id); - code = activity.placeholderCode; - } else if (activity.type === "lesson" && activity.example) { - uri = this.exampleFileUri(unit.id, activity.example.id); - code = activity.example.code; - } else { - continue; - } - // Save any unsaved edits first so the editor is clean, then overwrite - // the file on disk. Abort the whole unit reset if a save didn't take, - // rather than clearing progress on a file we couldn't actually reset. - if (!(await this.saveOpenDocument(uri))) { - throw new Error( - "Couldn't save an open file in this unit. Save or close it, then try again.", - ); - } - await ensureParentDir(uri); - await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(code)); + // Close any open notebook tab first. If the learner cancels a + // save-on-close prompt, abort — otherwise the still-open editor could + // later save stale content back over the reset. + if (!(await this.closeNotebookTab(workbookUri(unit)))) { + throw new Error( + "Couldn't close the open notebook. Save or close it, then try again.", + ); + } + if (!(await rematerializeUnitWorkbook(unit))) { + throw new Error("Couldn't restore the unit's notebook."); } return this.finishUnitReset(course.id, unit, source); } - /** Clear the unit's completions and persist, shared by both reset paths. */ + /** Clear the unit's completions and persist after a unit reset. */ private async finishUnitReset( courseId: string, unit: CatalogUnit, From b699b29a8d1efff02f8a91b8f14b228e9efec901 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 15 Sep 2026 14:44:57 -0700 Subject: [PATCH 06/10] Record unit resets as unit level telemetry --- source/vscode/src/learning/service.ts | 12 +++++------- source/vscode/src/telemetry.ts | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index b51d0849e3d..db0e3e72464 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -955,10 +955,6 @@ export class LearningService { if (!uri) { throw new Error("Current activity has no associated code file."); } - // TODO: saveOpenDocument now reports whether the save landed. If it - // didn't, this reads stale code off disk and checks the wrong content. - // Read the open document's in-memory text directly rather than saving - // then re-reading. await this.saveOpenDocument(uri); const bytes = await vscode.workspace.fs.readFile(uri); return new TextDecoder().decode(bytes); @@ -1121,7 +1117,9 @@ export class LearningService { await this.saveProgress(); this._onDidChangeState.fire(this.getState()); if (source) { - this.sendActivityActionTelemetry("reset-unit", source); + // A unit reset spans every activity in the unit, so record it as a + // unit-level action rather than borrowing the current activity's type. + this.sendActivityActionTelemetry("reset-unit", source, "unit"); } return { unitId: unit.id, unitTitle: unit.title }; } @@ -1281,8 +1279,8 @@ export class LearningService { | "reset" | "reset-unit", source: TelemetrySource, - activityType: CatalogActivity["type"] = this.findCurrentActivity().activity - .type, + activityType: CatalogActivity["type"] | "unit" = this.findCurrentActivity() + .activity.type, ): void { sendTelemetryEvent( EventType.LearningActivityAction, diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index 5c61d96b67c..6a991550e2d 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -351,7 +351,7 @@ type EventTypes = { | "solution" | "reset" | "reset-unit"; - activityType: "lesson" | "exercise" | "code-cell"; + activityType: "lesson" | "exercise" | "code-cell" | "unit"; source: "panel" | "chat" | "tree" | "notebook"; }; measurements: Empty; From a5ed8ea08f05493cb9dba417f314ee5542b74c4e Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Tue, 15 Sep 2026 15:49:35 -0700 Subject: [PATCH 07/10] Judge reset's cell guard from the active notebook, not stored position --- source/vscode/src/gh-copilot/learningTools.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 0d51c726322..30073a14a60 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -347,21 +347,22 @@ export class LearningTools { } /** - * True when the active editor is the current unit's workbook but the - * selected cell has no stable id. In that case {@link serializeState} would - * silently fall back to the stored position, which is unsafe for reset. + * True when the learner is on a course workbook but the selected cell has no + * stable id, so {@link serializeState} would fall back to the stored + * position — unsafe for reset. Judged from the active editor's URI so a + * different unit's workbook is still covered. */ private notebookSelectionUnidentified(): boolean { - if (!isNotebookCourse(this.service.getActiveCourseInfo())) { + const editor = vscode.window.activeNotebookEditor; + if (!editor || !this.service.isCourseWorkbook(editor.notebook.uri)) { return false; } - const editor = vscode.window.activeNotebookEditor; - const workbook = this.service.getCurrentCodeFileUri(); - const onWorkbook = - !!editor && - !!workbook && - editor.notebook.uri.toString() === workbook.toString(); - return onWorkbook && this.selectedNotebookCellId() === undefined; + const selection = editor.selections[0]; + if (!selection) { + return true; + } + const cellId = editor.notebook.cellAt(selection.start).metadata?.id; + return typeof cellId !== "string"; } /** From 6f6ad123bd4f7c8e251018a1f1096d250941ffd9 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Wed, 16 Sep 2026 11:38:09 -0700 Subject: [PATCH 08/10] Simplify reset confirmation and fix toolbar reset target - The chat reset now always asks for confirmation instead of guessing from stored state, which could skip the prompt and reset the wrong cell. - The notebook toolbar reset now targets the notebook you're looking at, not a stale stored unit. Removes the dead confirmReset, selectedNotebookCellId, and getCurrentActivityType. --- source/vscode/src/gh-copilot/learningTools.ts | 61 ------------------- source/vscode/src/gh-copilot/tools.ts | 8 ++- source/vscode/src/learning/commands.ts | 26 +++++--- source/vscode/src/learning/service.ts | 5 -- 4 files changed, 25 insertions(+), 75 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 30073a14a60..52b8fda621f 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -6,7 +6,6 @@ import { LearningService, LEARNING_WORKSPACE_FOLDER, detectLearningWorkspace, - isNotebookCourse, resolveNewWorkspaceRoot, type CourseDescriptor, type CurrentActivity, @@ -365,47 +364,6 @@ export class LearningTools { return typeof cellId !== "string"; } - /** - * Confirmation for the reset tool. Returns `undefined` — no prompt — when - * the current activity has no code to restore, so the user isn't asked to - * approve a reset that will fail. - * - * **Must be free of side-effects** — only reads state and the editor. - */ - confirmReset(): vscode.PreparedToolInvocation | undefined { - const confirmation: vscode.PreparedToolInvocation = { - confirmationMessages: { - title: "Reset Activity", - message: - "Reset the current activity to its starter code? Your code will be lost.", - }, - }; - - // Uninitialized or unreadable state: let invoke() surface the problem. - if (!this.service.initialized) { - return confirmation; - } - - try { - if (!isNotebookCourse(this.service.getActiveCourseInfo())) { - // Q# courses can only reset exercises. - return this.service.getCurrentActivityType() === "exercise" - ? confirmation - : undefined; - } - - const cellId = this.selectedNotebookCellId(); - if (cellId === undefined) { - // No workbook cell selected — the reset targets the stored position. - return confirmation; - } - return this.service.isActivityCellId(cellId) ? confirmation : undefined; - } catch { - // Never block the tool on a confirmation-time failure. - return confirmation; - } - } - /** * Reset an entire unit, clearing completion for all of its activities. * Defaults to the current unit. @@ -488,25 +446,6 @@ export class LearningTools { await vscode.commands.executeCommand("qsharp-vscode.learningShowActivity"); } - /** - * The id of the cell selected in the active notebook editor, when that - * editor is the current unit's workbook. `undefined` otherwise, in which - * case callers fall back to the stored position. - */ - private selectedNotebookCellId(): string | undefined { - const editor = vscode.window.activeNotebookEditor; - const selection = editor?.selections[0]; - if (!editor || !selection) { - return undefined; - } - const workbook = this.service.getCurrentCodeFileUri(); - if (!workbook || editor.notebook.uri.toString() !== workbook.toString()) { - return undefined; - } - const cellId = editor.notebook.cellAt(selection.start).metadata?.id; - return typeof cellId === "string" ? cellId : undefined; - } - private getCurrentFileUri(): vscode.Uri { const uri = this.service.getCurrentCodeFileUri(); if (!uri) { diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 4eef1f11260..480f231ed68 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -184,7 +184,13 @@ const toolDefinitions: { { name: "qdk-learning-reset", tool: async () => await learningTools!.resetExercise(), - confirm: () => learningTools!.confirmReset(), + confirm: (): vscode.PreparedToolInvocation => ({ + confirmationMessages: { + title: "Reset Activity", + message: + "Reset the current activity to its starter code? Your code will be lost.", + }, + }), }, { name: "qdk-learning-reset-unit", diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 52b404092c1..f73d2c5b9c0 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -63,6 +63,20 @@ export function registerLearningCommands( await service.switchCourse(location.courseId, "tree"); } await service.goTo(location, "tree"); + } else { + // Invoked from the notebook toolbar: point the stored position at the + // notebook the learner is actually looking at before we reset by + // position, so a not-yet-synced editor switch can't reset a different + // unit than the visible one. If we can't confirm which workbook is + // active, don't guess at a destructive reset — abort. + const activeNotebook = + vscode.window.activeNotebookEditor?.notebook.uri; + if ( + !activeNotebook || + !(await service.syncToWorkbook(activeNotebook)) + ) { + return; + } } const confirmed = await vscode.window.showWarningMessage( @@ -76,16 +90,12 @@ export function registerLearningCommands( await service.resetUnit( location ? { unitId: location.unitId } : undefined, - "tree", + location ? "tree" : "notebook", ); - // Notebook courses don't use the lesson panel, and the reset closed - // the workbook, so re-open the fresh copy instead. - if (isNotebookCourse(service.getActiveCourseInfo())) { - await openCourseNotebook(service, { reveal: "top" }); - } else { - await panelManager.show(); - } + // Unit reset is notebook-only and closes the workbook, so re-open the + // fresh copy at the top. + await openCourseNotebook(service, { reveal: "top" }); vscode.window.showInformationMessage("Unit has been reset."); }, ), diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index db0e3e72464..1eb5d927d88 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -673,11 +673,6 @@ export class LearningService { }; } - /** The type of the activity at the current position. */ - getCurrentActivityType(): CatalogActivity["type"] { - return this.findCurrentActivity().activity.type; - } - /** * The notebook cell ID backing the current activity — the inverse of * {@link goToActivityByCellId}. `undefined` when the course isn't a From b65757e228f66ae461b35d5a27bb07611a7d0a7a Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Wed, 16 Sep 2026 12:07:57 -0700 Subject: [PATCH 09/10] Resolve chat unit reset from the active notebook --- source/vscode/src/gh-copilot/learningTools.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 52b8fda621f..b812dfd544b 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -373,6 +373,18 @@ export class LearningTools { }): Promise<{ unitId: string; unitTitle: string } & StateSnapshot> { await this.ensureInitialized(); return this.invoke(async () => { + // With no explicit unit, resolve the target from the notebook the + // learner is viewing rather than the stored position: sync the position + // to the active workbook first, matching how the other tools resolve + // from the editor. Best-effort — with no workbook focused we fall back + // to the stored current unit. + if (!input?.unitId) { + const activeNotebook = vscode.window.activeNotebookEditor?.notebook.uri; + if (activeNotebook) { + await this.service.syncToWorkbook(activeNotebook); + } + } + // Unit reset is notebook-only; the service rejects Q# courses. const { unitId, unitTitle } = await this.service.resetUnit( { unitId: input?.unitId }, From 8dd449e86e6d1497e6197edf6b0292e15592fa61 Mon Sep 17 00:00:00 2001 From: HABER7789 Date: Wed, 16 Sep 2026 13:30:50 -0700 Subject: [PATCH 10/10] Restore the authored cell kind on notebook reset --- .../vscode/src/learning/notebookExercises.ts | 72 +++++++++++++++---- .../src/learning/python/materialization.ts | 39 +++++----- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index de2adc71c95..ea69bbb6754 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -55,6 +55,10 @@ interface RawCell { /** The subset of an nbformat notebook this module reads. */ interface RawNotebook { cells?: unknown; + metadata?: { + language_info?: { name?: unknown }; + kernelspec?: { language?: unknown }; + }; } /** A {@link RawNotebook} whose `cells` array has been validated to exist. */ @@ -215,28 +219,66 @@ export function stripAuthoringCells( } /** - * Read a single cell's source text out of a notebook's JSON, matched by its - * stable nbformat cell ID. + * The authored form of a single cell: its source, cell kind, and the + * notebook's kernel language. Reset restores all three, so a learner who + * converted the exercise cell to Markdown gets a runnable code cell back + * rather than the starter text stranded in the wrong cell type. */ -export function findCellSource( +export interface AuthoredCell { + source: string; + kind: "code" | "markdown" | "other"; + language: string | undefined; +} + +/** + * Read a single cell's authored source and kind out of a notebook's JSON, + * matched by its stable nbformat cell ID, along with the notebook's kernel + * language. + */ +export function findAuthoredCell( text: string, cellId: string, unitLabel: string, -): string | undefined { - const cells = readCells(text, unitLabel); - const cell = cells?.find((c) => cellIdOf(c) === cellId); - return cell ? cellSource(cell) : undefined; +): AuthoredCell | undefined { + const notebook = parseNotebook(text, unitLabel); + if (!notebook) { + return undefined; + } + const cell = notebook.cells.find((c) => cellIdOf(c) === cellId); + if (!cell) { + return undefined; + } + return { + source: cellSource(cell), + kind: cellKind(cell), + language: notebookLanguage(notebook), + }; +} + +/** + * The notebook's kernel language (e.g. `"python"`), read from its nbformat + * metadata. Used to rebuild a code cell; `undefined` when unspecified. + */ +function notebookLanguage(notebook: ParsedNotebook): string | undefined { + const name = notebook.metadata?.language_info?.name; + if (typeof name === "string") { + return name; + } + const language = notebook.metadata?.kernelspec?.language; + return typeof language === "string" ? language : undefined; } /** - * Return the notebook JSON with one cell's source replaced, matched by its - * stable nbformat cell ID. The replaced cell's outputs are cleared, since - * restored code has not been run. + * Return the notebook JSON with one cell restored to its authored source and + * kind, matched by its stable nbformat cell ID. Restoring the kind brings a + * cell the learner converted to Markdown back to a runnable code cell. A + * restored code cell is given empty run state, since it has not been run. */ export function replaceCellSource( text: string, cellId: string, newSource: string, + newKind: "code" | "markdown" | "other", unitLabel: string, ): string | undefined { const notebook = parseNotebook(text, unitLabel); @@ -249,11 +291,17 @@ export function replaceCellSource( return undefined; } + const isCode = newKind === "code"; + cell.cell_type = isCode ? "code" : "markdown"; cell.source = newSource; - // A restored cell has never been run, so drop any stale execution state. - if (cellKind(cell) === "code") { + if (isCode) { + // A restored code cell has never been run, so give it empty run state. (cell as { outputs?: unknown }).outputs = []; (cell as { execution_count?: unknown }).execution_count = null; + } else { + // Markdown cells carry no run state. + delete (cell as { outputs?: unknown }).outputs; + delete (cell as { execution_count?: unknown }).execution_count; } // Match the ipynb serializer's formatting so the file stays diff-stable diff --git a/source/vscode/src/learning/python/materialization.ts b/source/vscode/src/learning/python/materialization.ts index 411d4353e24..d10e085a3e4 100644 --- a/source/vscode/src/learning/python/materialization.ts +++ b/source/vscode/src/learning/python/materialization.ts @@ -6,9 +6,10 @@ import * as vscode from "vscode"; import { sourceNotebookUri, workbookUri } from "../courseLayout.js"; import { ensureParentDir, uriExists } from "../fsUtils.js"; import { - findCellSource, + findAuthoredCell, replaceCellSource, stripAuthoringCells, + type AuthoredCell, } from "../notebookExercises.js"; import type { NotebookCatalogCourse, NotebookCatalogUnit } from "../types.js"; @@ -59,8 +60,8 @@ export async function restoreUnitWorkbookCell( const srcText = new TextDecoder().decode( await vscode.workspace.fs.readFile(sourceNotebookUri(unit)), ); - const authoredSource = findCellSource(srcText, cellId, unit.id); - if (authoredSource === undefined) { + const authored = findAuthoredCell(srcText, cellId, unit.id); + if (authored === undefined) { log.warn( `Cell ${cellId} not found in the authored notebook for unit "${unit.id}".`, ); @@ -72,7 +73,7 @@ export async function restoreUnitWorkbookCell( (n) => n.uri.toString() === dest.toString(), ); if (open) { - return replaceOpenCell(open, cellId, authoredSource); + return replaceOpenCell(open, cellId, authored); } const destText = new TextDecoder().decode( @@ -81,7 +82,8 @@ export async function restoreUnitWorkbookCell( const updated = replaceCellSource( destText, cellId, - authoredSource, + authored.source, + authored.kind, unit.id, ); if (updated === undefined) { @@ -104,19 +106,19 @@ export async function restoreUnitWorkbookCell( } /** - * Replace one cell of an open notebook with its authored source, preserving - * the cell id and tags while dropping outputs and execution state. The - * in-editor edit is the reset: it takes effect the moment the cell shows the - * authored source, and the notebook is then saved to disk best-effort. Returns - * `false` only when the cell is missing or the edit is rejected. A save that - * can't complete leaves the reset cell unsaved in the editor — like any other - * pending edit — instead of undoing the reset, so we never leave the cell + * Replace one cell of an open notebook with its authored source and kind, + * preserving the cell id and tags while dropping outputs and execution state. + * The in-editor edit is the reset: it takes effect the moment the cell shows + * the authored source, and the notebook is then saved to disk best-effort. + * Returns `false` only when the cell is missing or the edit is rejected. A save + * that can't complete leaves the reset cell unsaved in the editor — like any + * other pending edit — instead of undoing the reset, so we never leave the cell * showing placeholder code and then report the reset as a failure. */ async function replaceOpenCell( notebook: vscode.NotebookDocument, cellId: string, - source: string, + authored: AuthoredCell, ): Promise { const index = notebook.getCells().findIndex((c) => c.metadata?.id === cellId); if (index < 0) { @@ -124,10 +126,15 @@ async function replaceOpenCell( } const existing = notebook.cellAt(index); + // Restore the authored kind and language, not the learner's current ones: if + // they converted the exercise cell to Markdown, reset must bring back a + // runnable code cell. Fall back to Python — these are Python-notebook + // courses — when the notebook declares no kernel language. + const isCode = authored.kind === "code"; const data = new vscode.NotebookCellData( - existing.kind, - source, - existing.document.languageId, + isCode ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + authored.source, + isCode ? (authored.language ?? "python") : "markdown", ); // Keep the metadata so the stable cell id and its tags survive the replace. data.metadata = existing.metadata;