From 06c42eb062498bcbe7a9edc09709d1865ad0a386 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 12 May 2026 17:54:17 +0000 Subject: [PATCH 01/26] skills, logo, katas generation --- source/npm/qsharp/generate_katas_content.js | 20 +++- source/npm/qsharp/src/katas.ts | 5 + source/vscode/agents/qdk-learning.agent.md | 103 ++++++++++++++++++ source/vscode/prompts/qdk-learning.prompt.md | 8 ++ source/vscode/resources/mobius.svg | 8 ++ source/vscode/skills/qdk-programming/SKILL.md | 4 + 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 source/vscode/agents/qdk-learning.agent.md create mode 100644 source/vscode/prompts/qdk-learning.prompt.md create mode 100644 source/vscode/resources/mobius.svg diff --git a/source/npm/qsharp/generate_katas_content.js b/source/npm/qsharp/generate_katas_content.js index 854164f175..928acef31e 100644 --- a/source/npm/qsharp/generate_katas_content.js +++ b/source/npm/qsharp/generate_katas_content.js @@ -439,10 +439,25 @@ function createExerciseSection(kataPath, properties, globalCodeSources) { const exercisePath = join(kataPath, properties.path); // Generate the object using the macro properties. // Get the description from the index.md file in the exercise folder. - const descriptionMarkdown = tryReadFile( + let descriptionMarkdown = tryReadFile( join(exercisePath, "index.md"), `Could not read index.md file for exercise ${properties.id}`, ); + + // Strip inline hint blocks from exercise descriptions when requested. + // The VS Code extension provides hints through a separate AI-powered + // button instead of the embedded
blocks. + const hintPattern = + /
\s*[\s\S]*?Need a hint[\s\S]*?<\/summary>([\s\S]*?)<\/details>/gi; + let extractedHints; + if (!emitHtml) { + // Capture the inner content of each hint block before stripping. + extractedHints = [...descriptionMarkdown.matchAll(hintPattern)].map((m) => + m[1].trim(), + ); + descriptionMarkdown = descriptionMarkdown.replace(hintPattern, "").trim(); + } + const description = createTextContent(descriptionMarkdown); // Aggregate the exercise sources. The verification source file is Verification.qs. @@ -483,6 +498,9 @@ function createExerciseSection(kataPath, properties, globalCodeSources) { sourceIds, placeholderCode, explainedSolution, + ...(extractedHints && extractedHints.length > 0 + ? { hints: extractedHints } + : {}), }; } diff --git a/source/npm/qsharp/src/katas.ts b/source/npm/qsharp/src/katas.ts index ab6e2accee..f3dd924760 100644 --- a/source/npm/qsharp/src/katas.ts +++ b/source/npm/qsharp/src/katas.ts @@ -37,6 +37,11 @@ export type Exercise = { sourceIds: string[]; placeholderCode: string; explainedSolution: ExplainedSolution; + /** + * Hints extracted from index.md
blocks. + * Only populated in the Markdown bundle (used by VS Code); undefined in the HTML bundle (playground). + */ + hints?: string[]; }; export type Answer = { diff --git a/source/vscode/agents/qdk-learning.agent.md b/source/vscode/agents/qdk-learning.agent.md new file mode 100644 index 0000000000..c6a395b679 --- /dev/null +++ b/source/vscode/agents/qdk-learning.agent.md @@ -0,0 +1,103 @@ +--- +name: QDK Learning +description: "Learn quantum computing interactively with the Quantum Katas — guided lessons, hands-on exercises, and Q# code you can run, check, and explore right in VS Code." +model: "Claude Haiku 4.5 (copilot)" +--- + +# Quantum Development Kit Learning + +You are an agent that helps users navigate and interact with the Quantum Katas panel in VS Code. Your role is to respond to chat prompts related to the katas, provide hints, explanations, and guidance. + +The `qdk-learning-*` tools drive a **Quantum Katas panel** in VS Code. The panel renders the current activity, action bar, and progress bar. Its buttons handle navigation, run, check, etc. directly — they bypass the LLM. Your job: set up the workspace, show the current activity, then step aside. You only handle chat prompts and concept questions. + +## Definitions + +Following is a user-ready description of the Quantum Katas. You may refer to it if the user asks what the katas are or how they work. + +> Quantum Katas (_kaˑta_ | kah-tuh — Japanese for "form", a pattern of learning and practicing new skills) are self-paced, AI-assisted tutorials for quantum computing and Q# programming. Each tutorial includes relevant theory and interactive hands-on exercises designed to test knowledge. + +The tools refer to each kata as a "unit." Each unit contains ordered activities (lessons, examples, exercises). + +**Tool naming:** All learning tools share the `qdk-learning-` prefix. This document uses short names (e.g. `show` for `qdk-learning-show`). + +## Key Rules + +1. **Always get fresh state.** Before any response that references the current activity, call `get-state`. The user may have clicked around in the panel — those clicks bypass you. Stale state → wrong answers. +2. **Don't echo the activity content.** The panel renders it. Reprinting in chat is noise. +3. **Do render tool results in chat.** The panel shows the activity content, not tool output. When you call run/check/hint/etc., present the result in chat. + +## Startup + +Call `get-state` first. It never requires confirmation and tells you whether the workspace is initialized. + +- **If `initialized: true`** — you have the current position and progress. Greet the user briefly, then call `show` to open the activity panel. Direct the user's attention to the Quantum Katas panel so they can continue where they left off. +- **If `initialized: false`** — the workspace hasn't been set up yet. Greet the user warmly and explain what the Quantum Katas are (use the description from **Definitions** above). Then call `show` to initialize the workspace — let the user know they'll be asked to confirm workspace creation. Once initialized, direct them to the panel to get started. + +Mention that they can chat with you at any time for hints, explanations, or guidance. Don't explain how the agent works, list tools, or show menus. + +## Tone + +Warm, friendly tutor. Celebrate passes, encourage on failures, use natural language. + +## Panel Behavior + +Panel actions (Next, Run, Check, Solution…) work directly — no LLM round-trip. You're only invoked when the user types in chat or invokes one of the panel actions that explicitly routes a message to chat. + +### Chat Entry Points + +The panel routes these messages to chat. Always call `get-state` first to understand context. + +| Button / Link | Shown on | Chat message | +| --------------------- | --------------------- | ------------------------------------------- | +| **Hint** | Exercises | "Give me a hint" | +| **Explain** | Lessons & examples | "Explain this concept in more detail" | +| What went wrong? | Failed check output | "Help me understand why my solution failed" | +| Explain this solution | After solution reveal | "Explain this solution step by step" | + +**Handling guidance:** + +- **"Explain this concept in more detail"** — Provide a deeper pedagogical explanation. Offer analogies, relate to prior units. Don't repeat the panel content. +- **"Help me understand why my solution failed"** — Analyze common mistakes for that exercise. Give targeted debugging hints, not the full solution. +- **"Explain this solution step by step"** — Walk through the reference solution line by line, explaining the quantum concepts and Q# patterns. + +## Procedure + +### 1. Show Activity + +Call `show`. Use the returned state for your greeting. Don't call on every turn — use `get-state` for silent reads between turns. + +To start a specific unit: `list-units` → find `unitId` → `goto`. + +### 2. Route Chat Input + +Call `get-state` first. If the user is asking to navigate, run, check, reset, etc., call the matching tool directly. Notable cases: + +- **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 +- **"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. +- **free-form question** → answer using knowledge + current state; no tool needed + +Render tool results in chat. Keep responses short and tutor-like. + +### Hint Strategy + +When the user asks for a hint (or clicks the Hint button): + +1. Call `hint` and `read-code` together. The hint tool returns `hints` (short author-written nudges) and `solutionExplanation` (deeper prose walkthrough). The code shows what the user has tried so far. +2. Reveal hints **one at a time** ("Hint 1/N"). If the user's code already satisfies a hint, acknowledge briefly and skip ahead to the next applicable one. +3. On subsequent "another hint" requests, continue through the list — don't re-call the tool. +4. When author hints are exhausted, **paraphrase** `solutionExplanation` as a deeper nudge (don't dump verbatim). +5. If the tool returns no hints, generate a pedagogical hint yourself from the exercise description and Q# knowledge. + +### After a Passing Check + +Render the result, offer a brief reaction. Don't auto-call `next` — the user may want to review the solution first. + +## Don'ts + +- Don't echo activity content in chat +- 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 diff --git a/source/vscode/prompts/qdk-learning.prompt.md b/source/vscode/prompts/qdk-learning.prompt.md new file mode 100644 index 0000000000..852684a642 --- /dev/null +++ b/source/vscode/prompts/qdk-learning.prompt.md @@ -0,0 +1,8 @@ +--- +name: qdk-learning +description: Chat with the QDK Learning agent +agent: QDK Learning +argument-hint: Chat with the QDK Learning agent. e.g. "give me a hint", "check my solution", "run my code" +--- + +Let's do the Quantum Katas. diff --git a/source/vscode/resources/mobius.svg b/source/vscode/resources/mobius.svg new file mode 100644 index 0000000000..87346b689a --- /dev/null +++ b/source/vscode/resources/mobius.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/source/vscode/skills/qdk-programming/SKILL.md b/source/vscode/skills/qdk-programming/SKILL.md index b058da2b04..ed3c589f8a 100644 --- a/source/vscode/skills/qdk-programming/SKILL.md +++ b/source/vscode/skills/qdk-programming/SKILL.md @@ -38,6 +38,10 @@ Most QDK features work in two modes: | **Q#/OpenQASM in Python and/or Jupyter** | — (inherently Python) | [python.md](./python.md) | | **Qiskit / Cirq / PennyLane interop** | — (inherently Python) | [python.md](./python.md) — Framework Interop | +**Quantum Katas** + +Quantum Katas are a collection of self-paced tutorials built into the QDK that teach quantum computing from the ground up. If the user wants to learn through the Quantum Katas, encourage them to use the `/qdk-learning` prompt to chat with the QDK Learning agent, which provides a guided, chat-embedded experience. + ## Deprecations - The QDK was rewritten in 2024. It no longer uses the IQ# Jupyter kernel or `dotnet` CLI tools. From 63ed08df67bb3ed94fd7379bd81fa3f33212ed81 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 12 May 2026 19:57:16 +0000 Subject: [PATCH 02/26] copilot tools --- source/vscode/src/gh-copilot/learningTools.ts | 313 ++++++++++++++++++ source/vscode/src/gh-copilot/tools.ts | 96 +++++- 2 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 source/vscode/src/gh-copilot/learningTools.ts diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts new file mode 100644 index 0000000000..4a12e59c42 --- /dev/null +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { + LearningService, + LEARNING_WORKSPACE_FOLDER, + detectLearningWorkspace, + resolveNewWorkspaceRoot, + type HintContext, + type UnitSummary, + type OverallProgress, + type CurrentActivity, + type RunResult, + type SolutionCheckResult, +} from "../learning/index.js"; +import { CopilotToolError } from "./types.js"; + +/** + * Compact snapshot of the learner's current position and progress. + * + * Returned alongside every learning-tool response so the language model + * always has up-to-date context about where the student is in the + * curriculum without needing a separate round-trip. + */ +export interface SerializedLearningState { + position: CurrentActivity; + progress: { + totalActivities: number; + completedActivities: number; + currentUnitCompleted: number; + currentUnitTotal: number; + }; +} + +/** + * Wraps the shared {@link LearningService} singleton for use as + * `vscode.lm` language model tools. + */ +export class LearningTools { + constructor(private readonly service: LearningService) {} + + /** + * Called by `prepareInvocation` on almost every learning tool. + * + * Returns a confirmation prompt when the workspace needs first-time + * setup, or `undefined` to skip confirmation when setup already exists + * or the service is loaded. + * + * **Must be free of side-effects** — only reads state and the filesystem. + */ + async confirmInit(): Promise { + if (this.service.initialized) { + return undefined; + } + + // If the progress file already exists on disk, skip confirmation — + // the workspace was previously set up and we just need to re-load state. + const detected = await detectLearningWorkspace(); + if (detected) { + return undefined; + } + + const newRoot = resolveNewWorkspaceRoot(); + if (!newRoot) { + // No workspace — let invoke() surface the error. + return undefined; + } + const workspacePath = newRoot.fsPath; + + return { + confirmationMessages: { + title: "Initialize QDK Learning workspace", + message: + `Set up a Quantum Katas learning workspace in **${workspacePath}**? ` + + `Exercise files and progress tracking will be created in a \`${LEARNING_WORKSPACE_FOLDER}\` subfolder.`, + }, + }; + } + + /** + * Ensures the learning service is initialized, creating workspace + * files if needed. Called at the start of every tool invocation + * (after the user has already approved via {@link confirmInit}). + */ + private async ensureInitialized(): Promise { + const ok = await this.service.ensureInitialized({ createIfMissing: true }); + if (!ok) { + throw new CopilotToolError( + "No workspace folder is open. Open a folder first, then try again.", + ); + } + } + + // ─── Read-only queries (do not open the panel) ─── + + /** + * Read the current learning position and progress. + * + * Returns `{ initialized: false }` when the workspace is not yet set up, + * so the caller can decide whether to prompt for initialization. + */ + async getState(): Promise< + | { initialized: false } + | { initialized: true; state: SerializedLearningState } + > { + if (!this.service.initialized) { + const detected = await detectLearningWorkspace(); + if (!detected) { + return { initialized: false }; + } + await this.ensureInitialized(); + } + return { initialized: true, state: this.serializeState() }; + } + + /** + * Return the full per-kata progress breakdown. + */ + async getProgress(): Promise<{ + progress: OverallProgress; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const progress = this.service.getProgress(); + return { + progress, + state: this.serializeState(), + }; + } + + /** + * List all available units with completion status. + */ + async listUnits(): Promise<{ + units: UnitSummary[]; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + return { + units: this.service.listUnits(), + state: this.serializeState(), + }; + } + + /** + * Read the user's current Q# code at the active exercise or example. + */ + async readCode(): Promise<{ + code: string; + filePath: string; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const uri = this.getCurrentFileUri(); + const pos = this.service.getPosition(); + const code = + pos.content.type === "exercise" + ? await this.service.readUserCode() + : new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + return { code, filePath: uri.fsPath, state: this.serializeState() }; + } + + /** + * Return all built-in hints for the current exercise. + */ + async hint(): Promise<{ + result: HintContext | null; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const r = this.service.getHintContext("chat"); + return { result: r.result, state: this.serializeState() }; + } + + // ─── Navigation & actions (open the panel) ─── + + /** + * Show the current learning activity. + */ + async show(): Promise<{ state: SerializedLearningState }> { + await this.ensureInitialized(); + await this.showActivity(); + return { state: this.serializeState() }; + } + + /** + * Move to the next item. + */ + async next(): Promise<{ moved: boolean; state: SerializedLearningState }> { + await this.ensureInitialized(); + const r = this.service.next("chat"); + await this.showActivity(); + return { moved: r.moved, state: this.serializeState() }; + } + + /** + * Move to the previous item. + */ + async previous(): Promise<{ + moved: boolean; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const r = this.service.previous("chat"); + await this.showActivity(); + return { moved: r.moved, state: this.serializeState() }; + } + + /** + * Jump to a specific unit/activity. + */ + async goTo(input: { + courseId?: string; + unitId: string; + activityId?: string; + }): Promise<{ state: SerializedLearningState }> { + await this.ensureInitialized(); + this.service.goTo(input.unitId, input.activityId, "chat"); + await this.showActivity(); + return { state: this.serializeState() }; + } + + /** + * Run the Q# code at the current position. + */ + async run(input: { + shots?: number; + }): Promise<{ result: RunResult; state: SerializedLearningState }> { + await this.ensureInitialized(); + const r = await this.service.run(input.shots ?? 1, "chat"); + await this.showActivity(); + return { result: r.result, state: this.serializeState() }; + } + + /** + * Check the student's solution. Marks it complete on pass. + */ + async check(): Promise<{ + result: SolutionCheckResult; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const r = await this.service.checkSolution("chat"); + await this.showActivity(); + return { result: r.result, state: this.serializeState() }; + } + + /** + * Reset the current exercise to its original placeholder code + * and clear its completion status. + */ + async resetExercise(): Promise<{ state: SerializedLearningState }> { + await this.ensureInitialized(); + await this.service.resetExercise("chat"); + await this.showActivity(); + return { state: this.serializeState() }; + } + + /** + * Show the full reference solution code. + */ + async solution(): Promise<{ + result: string; + state: SerializedLearningState; + }> { + await this.ensureInitialized(); + const result = this.service.getFullSolution("chat"); + await this.showActivity(); + return { result, state: this.serializeState() }; + } + + // ─── Helpers ─── + + private async showActivity(): Promise { + await vscode.commands.executeCommand("qsharp-vscode.learningShowActivity"); + } + + private getCurrentFileUri(): vscode.Uri { + const pos = this.service.getPosition(); + if (pos.content.type === "exercise") { + return this.service.getExerciseFileUri(); + } else if (pos.content.type === "lesson-example") { + return this.service.getExampleFileUri(); + } + throw new CopilotToolError( + "Current activity is not an exercise or example — there is no code to read.", + ); + } + + /** + * Build a compact snapshot of position and progress to attach to + * every tool response. + */ + private serializeState(): SerializedLearningState { + const state = this.service.getState(); + const progress = state.progress; + const cur = progress.currentPosition?.unitId; + const currentUnit = cur + ? progress.units.find((u) => u.id === cur) + : undefined; + + return { + position: state.position, + progress: { + totalActivities: progress.stats.totalActivities, + completedActivities: progress.stats.completedActivities, + currentUnitCompleted: currentUnit?.completed ?? 0, + currentUnitTotal: currentUnit?.total ?? 0, + }, + }; + } +} diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 1196449e1c..816dabd7b3 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -6,18 +6,23 @@ import * as vscode from "vscode"; import { EventType, sendTelemetryEvent, UserFlowStatus } from "../telemetry"; import { getRandomGuid } from "../utils"; import * as azqTools from "./azureQuantumTools"; +import { LearningTools } from "./learningTools"; import { QSharpTools } from "./qsharpTools"; import { CopilotToolError } from "./types"; import { ToolState } from "./azureQuantumTools"; +import type { LearningService } from "../learning/index"; // state const workspaceState: ToolState = {}; let qsharpTools: QSharpTools | undefined; +let learningTools: LearningTools | undefined; const toolDefinitions: { name: string; tool: (input: any) => Promise; - confirm?: (input: any) => vscode.PreparedToolInvocation; + confirm?: ( + input: any, + ) => vscode.ProviderResult; }[] = [ // match these to the "languageModelTools" entries in package.json { @@ -100,10 +105,85 @@ const toolDefinitions: { name: "qsharp-get-library-descriptions", tool: async () => await qsharpTools!.qsharpGetLibraryDescriptions(), }, + // ─── QDK Learning tools ─── + { + name: "qdk-learning-get-state", + tool: async () => await learningTools!.getState(), + }, + { + name: "qdk-learning-show", + tool: async () => await learningTools!.show(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-get-progress", + tool: async () => await learningTools!.getProgress(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-list-units", + tool: async () => await learningTools!.listUnits(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-next", + tool: async () => await learningTools!.next(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-previous", + tool: async () => await learningTools!.previous(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-goto", + tool: async (input) => await learningTools!.goTo(input), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-run", + tool: async (input) => await learningTools!.run(input), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-read-code", + tool: async () => await learningTools!.readCode(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-check", + tool: async () => await learningTools!.check(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-hint", + tool: async () => await learningTools!.hint(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-solution", + tool: async () => await learningTools!.solution(), + confirm: async () => learningTools!.confirmInit(), + }, + { + name: "qdk-learning-reset", + tool: async () => await learningTools!.resetExercise(), + confirm: () => ({ + confirmationMessages: { + title: "Reset Exercise", + message: + "Reset the current exercise to the original placeholder? Your code will be lost.", + }, + }), + }, ]; -export function registerLanguageModelTools(context: vscode.ExtensionContext) { +export function registerLanguageModelTools( + context: vscode.ExtensionContext, + learningService: LearningService, +) { qsharpTools = new QSharpTools(context.extensionUri); + learningTools = new LearningTools(learningService); for (const { name, tool: fn, confirm: confirmFn } of toolDefinitions) { context.subscriptions.push( vscode.lm.registerTool(name, tool(context, name, fn, confirmFn)), @@ -115,15 +195,17 @@ function tool( context: vscode.ExtensionContext, toolName: string, toolFn: (input: T) => Promise, - confirmFn?: (input: T) => vscode.PreparedToolInvocation, + confirmFn?: ( + input: T, + ) => vscode.ProviderResult, ): vscode.LanguageModelTool { return { invoke: (options: vscode.LanguageModelToolInvocationOptions) => invokeTool(context, toolName, options, toolFn), - prepareInvocation: - confirmFn && - ((options: vscode.LanguageModelToolInvocationPrepareOptions) => - confirmFn(options.input)), + prepareInvocation: confirmFn + ? (options: vscode.LanguageModelToolInvocationPrepareOptions) => + confirmFn(options.input) + : undefined, }; } From fac9c9a96860d8b8021d2d8d6a2f7034c5b3bde2 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 12 May 2026 21:26:06 +0000 Subject: [PATCH 03/26] webview app panel --- .../vscode/src/learning/webview/tsconfig.json | 16 + .../src/learning/webview/webview-client.tsx | 612 ++++++++++++++++++ .../vscode/src/learning/webview/webview.css | 385 +++++++++++ 3 files changed, 1013 insertions(+) create mode 100644 source/vscode/src/learning/webview/tsconfig.json create mode 100644 source/vscode/src/learning/webview/webview-client.tsx create mode 100644 source/vscode/src/learning/webview/webview.css diff --git a/source/vscode/src/learning/webview/tsconfig.json b/source/vscode/src/learning/webview/tsconfig.json new file mode 100644 index 0000000000..ae470034e6 --- /dev/null +++ b/source/vscode/src/learning/webview/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ES2022", + "noEmit": true, + "allowImportingTsExtensions": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "rootDir": ".", + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx new file mode 100644 index 0000000000..43fff40266 --- /dev/null +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -0,0 +1,612 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// + +const vscodeApi: WebviewApi = acquireVsCodeApi(); + +import { render } from "preact"; +import { useEffect, useReducer, useRef } from "preact/hooks"; +import { Markdown, setRenderer } from "qsharp-lang/ux"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - there are no types for this +import mk from "@vscode/markdown-it-katex"; +import markdownIt from "markdown-it"; +import type { + CurrentActivity, + ActionGroup, + OverallProgress, + ActivityContent, + SolutionCheckResult, + OutputEvent, + LearningState, + HostToWebviewMessage, +} from "../types.js"; +import { WebviewApi } from "vscode-webview"; + +const md = markdownIt("commonmark"); +md.use(mk, { enableMathBlockInHtml: true, enableMathInlineInHtml: true }); +setRenderer((input: string) => md.render(input)); + +// ─── State ─── + +type OutputState = { + variant?: "pass" | "fail"; +} & ( + | { type: "loading" } + | { type: "text"; text: string } + | { type: "check"; result: SolutionCheckResult } +); + +type AppState = { + learning: LearningState | null; + output: OutputState | null; + busy: boolean; +}; + +type AppAction = + /** Full state refresh (initial load, external progress change, etc.). */ + | { type: "stateUpdate"; state: LearningState } + /** A next/back navigation completed. If !moved, the user hit an edge. */ + | { + type: "navResult"; + direction: "next" | "back"; + moved: boolean; + state: LearningState; + } + /** An exercise check completed with pass/fail and captured output. */ + | { type: "checkResult"; result: SolutionCheckResult; state: LearningState } + /** A run completed (output shown in the editor, not here). */ + | { type: "runComplete"; state: LearningState } + /** An unrecoverable error during action execution. */ + | { type: "error"; message: string } + /** User triggered an action; sets busy flag and optionally shows a loading spinner. */ + | { type: "startAction"; slow: boolean } + /** User dismissed the output panel. */ + | { type: "dismissOutput" }; + +/** Given the current state and an action, returns the next state. No side effects. */ +function reducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case "stateUpdate": + return applyState(state, action.state, state.output); + case "navResult": { + let output = state.output; + if (!action.moved) { + output = + action.direction === "next" + ? { + type: "text", + text: "🎉 You have completed all content!", + variant: "pass", + } + : { type: "text", text: "Already at the beginning." }; + } + return applyState(state, action.state, output); + } + case "checkResult": + return applyState(state, action.state, { + type: "check", + result: action.result, + variant: action.result.passed ? "pass" : "fail", + }); + case "runComplete": + return applyState(state, action.state, null); + case "error": + return { + ...state, + output: { + type: "text", + text: "Error: " + action.message, + variant: "fail", + }, + busy: false, + }; + case "startAction": + return { + ...state, + busy: true, + output: action.slow ? { type: "loading" } : state.output, + }; + case "dismissOutput": + return { ...state, output: null }; + } +} + +/** Apply a new LearningState, clearing output if the activity changed. */ +function applyState( + prev: AppState, + learning: LearningState, + output: OutputState | null, +): AppState { + const p = prev.learning?.position; + const n = learning.position; + if (p?.unitId !== n.unitId || p?.activityId !== n.activityId) { + output = null; + } + return { learning, output, busy: false }; +} + +// ─── Helpers ─── + +function openFile(uri: string) { + vscodeApi.postMessage({ command: "openFile", uri }); +} + +function openChat(text: string) { + vscodeApi.postMessage({ command: "openChat", text }); +} + +function messageToAction(msg: HostToWebviewMessage): AppAction { + if (msg.command === "state") { + return { type: "stateUpdate", state: msg.state }; + } + if (msg.command === "error") { + return { type: "error", message: msg.message }; + } + switch (msg.action) { + case "next": + case "back": + return { + type: "navResult", + direction: msg.action, + moved: msg.result.moved, + state: msg.state, + }; + case "check": + return { + type: "checkResult", + result: msg.result, + state: msg.state, + }; + case "run": + return { type: "runComplete", state: msg.state }; + } +} + +// ─── Components ─── + +function App() { + const [appState, dispatch] = useReducer(reducer, null, () => ({ + learning: vscodeApi.getState() ?? null, + output: null, + busy: false, + })); + const { learning, output, busy } = appState; + + // Listen for messages from the extension host + useEffect(() => { + const handler = (event: MessageEvent) => { + dispatch(messageToAction(event.data)); + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + // Persist learning state for webview restoration + useEffect(() => { + if (learning) { + vscodeApi.setState(learning); + } + }, [learning]); + + // Signal readiness to the extension host + useEffect(() => { + vscodeApi.postMessage({ command: "ready" }); + }, []); + + const onAction = (action: string) => { + if (busy) return; + + if (action === "hint-chat") { + openChat("Give me a hint"); + return; + } + if (action === "explain-chat") { + openChat("Explain this concept in more detail"); + return; + } + + const slow = ["run", "check"].includes(action); + dispatch({ type: "startAction", slow }); + vscodeApi.postMessage({ command: "action", action }); + }; + + const onDismiss = () => dispatch({ type: "dismissOutput" }); + + if (!learning) { + return
Loading...
; + } + + return ( + <> + +
+ + {output ? : null} + + + + ); +} + +function Branding() { + const mobiusUri = document.body.dataset.mobiusUri ?? ""; + return ( +
+ Microsoft Quantum logo + Microsoft Quantum Katas +
+ ); +} + +function Header({ current }: { current: CurrentActivity }) { + const content = current.content; + const crumb = current.unitTitle + " › " + current.activityTitle; + + let badgeText: string; + let badgeClass = "badge"; + if (content.type === "exercise") { + badgeText = content.isComplete ? "✔ done" : "exercise"; + badgeClass += content.isComplete ? " complete" : " exercise"; + } else if (content.type === "lesson-text") { + badgeText = "lesson"; + } else { + badgeText = "example"; + } + + return ( +
+ {crumb} + {badgeText} +
+ ); +} + +function ContentBody({ + content, + activityKey, +}: { + content: ActivityContent; + activityKey: string | null; +}) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.scrollTop = 0; + } + }, [activityKey]); + + return ( +
+ {content.type === "lesson-text" && ( + + )} + {content.type === "lesson-example" && } + {content.type === "exercise" && } +
+ ); +} + +function LessonExample({ + content, +}: { + content: Extract; +}) { + return ( + <> + {content.contentBefore && } + {content.filePath && ( + + )} + {content.contentAfter && } + + ); +} + +function Exercise({ + content, +}: { + content: Extract; +}) { + return ( + <> +

{content.title ?? ""}

+ {content.description && } + {content.filePath && ( + + )} + {content.isComplete && ( +
+ Correct! +
+ )} + + ); +} + +function FilePathNote({ + message, + linkText, + filePath, +}: { + message: string; + linkText: string; + filePath: string; +}) { + return ( +

+ {message}{" "} + { + e.preventDefault(); + openFile(filePath); + }} + > + {linkText} + + . +

+ ); +} + +function OutputPanel({ + output: out, + onDismiss, +}: { + output: OutputState; + onDismiss: () => void; +}) { + const className = "output" + (out.variant ? " " + out.variant : ""); + const label = out.variant ? "Result" : "Output"; + + return ( +
+ +
{label}
+
+ +
+
+ ); +} + +function OutputBody({ output: out }: { output: OutputState }) { + switch (out.type) { + case "loading": + return
Working…
; + case "text": { + const cls = + out.variant === "pass" + ? "success" + : out.variant === "fail" + ? "fail" + : "message"; + return
{out.text}
; + } + case "check": + return ; + } +} + +function SolutionResult({ result }: { result: SolutionCheckResult }) { + return ( + <> + {result.passed ? ( +
✔ All tests passed!
+ ) : ( +
✘ Check failed
+ )} + + {result.error &&
{result.error}
} + {!result.passed && ( + openChat("Help me understand why my solution failed")} + > + What went wrong? + + )} + + ); +} + +function EventList({ events }: { events: OutputEvent[] }) { + return ( + <> + {events.map((event, i) => { + if (event.type === "message") { + return ( +
+ {event.message} +
+ ); + } + return null; + })} + + ); +} + +function ActionBar({ + groups, + busy: isBusy, + onAction, +}: { + groups: ActionGroup[]; + busy: boolean; + onAction: (action: string) => void; +}) { + // Keyboard shortcut handling + useEffect(() => { + const allBindings = groups.flat(); + const handler = (e: KeyboardEvent) => { + if (isBusy) { + return; + } + const tag = ((e.target as HTMLElement).tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") { + return; + } + + let key = e.key.toLowerCase(); + if (key === " ") { + key = "space"; + } + + // Space triggers the primary action + if (key === "space") { + const primary = allBindings.find((b) => b.primary); + if (primary) { + e.preventDefault(); + onAction(primary.action); + return; + } + } + + // Single-letter shortcuts + if (key.length === 1) { + const match = allBindings.find((b) => b.key === key); + if (match) { + e.preventDefault(); + onAction(match.action); + } + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [groups, isBusy, onAction]); + + return ( + + ); +} + +function ProgressBar({ progress }: { progress: OverallProgress }) { + const { stats, units, currentPosition } = progress; + const pct = + stats.totalActivities > 0 + ? Math.round((stats.completedActivities / stats.totalActivities) * 100) + : 0; + + const currentUnit = + units && currentPosition + ? units.find((k) => k.id === currentPosition.unitId) + : null; + + const onClick = () => { + vscodeApi.postMessage({ command: "focusProgress" }); + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + > + + {stats.completedActivities}/{stats.totalActivities} ({pct}%) + + {currentUnit ? ( + <> + + {currentPosition!.unitTitle || currentPosition!.unitId} + + + {currentUnit.activities.map((act) => { + const isCurrent = act.id === currentPosition!.activityId; + const cls = + "pb-seg" + + (act.isComplete ? " done" : isCurrent ? " current" : ""); + return ; + })} + + + ) : currentPosition && currentPosition.unitId ? ( + + {currentPosition.unitTitle || currentPosition.unitId} + + ) : null} +
+ ); +} + +// ─── Init ─── + +render(, document.body); diff --git a/source/vscode/src/learning/webview/webview.css b/source/vscode/src/learning/webview/webview.css new file mode 100644 index 0000000000..88d285068e --- /dev/null +++ b/source/vscode/src/learning/webview/webview.css @@ -0,0 +1,385 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT License. */ + +:root { + color-scheme: light dark; + --bg: var(--vscode-editor-background, transparent); + --fg: var(--vscode-editor-foreground, #1a1a1a); + --muted: var(--vscode-descriptionForeground, #6a6a6a); + --accent: var(--vscode-textLink-foreground, #0066cc); + --accent-hover: var(--vscode-textLink-activeForeground, var(--accent)); + --border: var(--vscode-widget-border, var(--vscode-panel-border, #e0e0e0)); + --success: var( + --vscode-charts-green, + var(--vscode-testing-iconPassed, #1a7f37) + ); + --fail: var(--vscode-errorForeground, var(--vscode-charts-red, #cf222e)); + --hint: var( + --vscode-charts-yellow, + var(--vscode-editorWarning-foreground, #825d00) + ); + --code-bg: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.08)); + --btn-bg: var(--vscode-button-background, var(--accent)); + --btn-fg: var(--vscode-button-foreground, #fff); + --btn-hover-bg: var(--vscode-button-hoverBackground, var(--accent)); + --btn-secondary-bg: var(--vscode-button-secondaryBackground, transparent); + --btn-secondary-fg: var(--vscode-button-secondaryForeground, var(--fg)); + --btn-secondary-hover-bg: var( + --vscode-button-secondaryHoverBackground, + transparent + ); + --focus-border: var(--vscode-focusBorder, var(--accent)); + --ui-font: var( + --vscode-font-family, + system-ui, + -apple-system, + "Segoe UI", + sans-serif + ); + --ui-font-size: var(--vscode-font-size, 13px); + --mono-font: var( + --vscode-editor-font-family, + ui-monospace, + "Cascadia Mono", + Menlo, + Monaco, + Consolas, + monospace + ); +} + +body { + font: var(--ui-font-size) / 1.45 var(--ui-font); + margin: 0; + background: var(--bg); + color: var(--fg); + display: flex; + flex-direction: column; + height: 100vh; +} + +math { + font-size: 1.25em; +} +math[display="block"] { + font-size: 1.35em; +} + +/* Branding header */ +.branding { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--bg); +} +.branding-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} +.branding-text { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--fg); + opacity: 0.85; +} + +/* Header */ +.header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + border-bottom: 1px solid var(--border); + font-size: 11px; + color: var(--muted); + flex-shrink: 0; +} +.header .crumb { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.header .badge { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--code-bg); + color: var(--fg); +} +.header .badge.exercise { + background: var(--vscode-badge-background, var(--accent)); + color: var(--vscode-badge-foreground, #fff); +} +.header .badge.complete { + background: var(--success); + color: var(--btn-fg, #fff); +} + +/* Content — full panel, scrollable (no max-height cap) */ +.content { + padding: 0.75rem 1rem; + overflow-y: auto; + flex: 1; + min-height: 0; +} +.content h1, +.content h2, +.content h3 { + margin: 0.4rem 0 0.3rem; +} +.content h1 { + font-size: 1.05rem; +} +.content h2 { + font-size: 1rem; +} +.content h3 { + font-size: 0.95rem; +} +.content p { + margin: 0.35rem 0; +} +.content ul, +.content ol { + margin: 0.35rem 0; + padding-left: 1.3rem; +} +.content pre { + background: var(--code-bg); + padding: 0.4rem 0.5rem; + border-radius: 4px; + overflow-x: auto; + font-size: 12px; + margin: 0.3rem 0; +} +.content code { + font-family: var(--mono-font); +} +.content table { + border-collapse: collapse; + margin: 0.25rem 0; + font-size: 12px; +} +.content th, +.content td { + border: 1px solid var(--border); + padding: 2px 6px; + text-align: left; +} +.content .file-path { + font-size: 11px; + color: var(--muted); + margin-top: 0.5rem; +} +.content .file-path-link { + color: var(--accent); + text-decoration: none; +} +.content .file-path-link:hover { + text-decoration: underline; +} + +/* Completion banner (inline in exercise body) */ +.content .completion-banner { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.6rem; + padding: 0.3rem 0.7rem; + border-radius: 4px; + background: color-mix(in srgb, var(--success) 12%, transparent); + color: var(--success); + font-size: 12px; + font-weight: 600; +} +.completion-banner .completion-icon { + font-size: 14px; +} + +/* Output slot */ +.output { + margin: 0 1rem 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + background: var(--code-bg); + font-size: 12px; + border-left: 3px solid var(--accent); + position: relative; + flex-shrink: 0; +} +.output.pass { + border-left-color: var(--success); +} +.output.fail { + border-left-color: var(--fail); +} +.output .out-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 0.25rem; +} +.output .out-dismiss { + position: absolute; + top: 4px; + right: 4px; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 5px; +} +.output .out-dismiss:hover { + color: var(--fg); +} +.output pre { + background: rgba(127, 127, 127, 0.12); + padding: 0.4rem 0.5rem; + border-radius: 3px; + font-size: 11px; + margin: 0.25rem 0; + overflow-x: auto; +} +.output table { + border-collapse: collapse; + margin: 0.25rem 0; + font-size: 11px; +} +.output th, +.output td { + border: 1px solid var(--border); + padding: 1px 5px; + text-align: left; +} +.output .success { + color: var(--success); + white-space: pre-wrap; +} +.output .fail { + color: var(--fail); + white-space: pre-wrap; +} +.output .message { + color: var(--muted); + font-style: italic; + white-space: pre-wrap; +} + +/* Action bar */ +.action-bar { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.4rem 0.75rem; + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.action-group { + display: flex; + gap: 0.25rem; + padding-right: 0.4rem; + border-right: 1px solid var(--border); +} +.action-group:last-child { + border-right: none; + padding-right: 0; +} +.action-bar button { + font: inherit; + font-size: 12px; + background: var(--btn-secondary-bg); + color: var(--btn-secondary-fg); + border: 1px solid var(--border); + padding: 3px 9px; + border-radius: 4px; + cursor: pointer; +} +.action-bar button:hover { + background: var(--btn-secondary-hover-bg); + border-color: var(--accent); +} +.action-bar button:focus-visible { + outline: 1px solid var(--focus-border); + outline-offset: 1px; +} +.action-bar button.primary { + background: var(--btn-bg); + color: var(--btn-fg); + border-color: var(--btn-bg); +} +.action-bar button.primary:hover { + background: var(--btn-hover-bg); + border-color: var(--btn-hover-bg); +} +.action-bar button:disabled { + opacity: 0.5; + cursor: wait; +} + +/* Progress bar footer */ +.progress-bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + font-size: 11px; + padding: 0.3rem 0.75rem; + color: var(--muted); + border-top: 1px solid var(--border); + flex-shrink: 0; + cursor: pointer; +} +.progress-bar:hover { + background: var(--btn-secondary-hover-bg); +} +.pb-segments { + display: inline-flex; + gap: 2px; +} +.pb-seg { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 2px; + background: var(--border); +} +.pb-seg.done { + background: var(--success); +} +.pb-seg.current { + background: var(--accent); +} + +/* Chat entry-point links (inline in output area) */ +.chat-link { + display: inline-block; + margin-top: 0.45rem; + font-size: 12px; + color: var(--accent); + cursor: pointer; + text-decoration: none; +} +.chat-link:hover { + text-decoration: underline; + color: var(--accent-hover); +} + +.loading { + color: var(--muted); + font-style: italic; +} From c6e869c8b74b1329005fea45dcd8e7b5bb6a72c2 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 12 May 2026 21:27:15 +0000 Subject: [PATCH 04/26] extension activation --- source/vscode/src/extension.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index dd6991d705..f4acdedda9 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -16,6 +16,7 @@ import { activateDebugger } from "./debugger/activate.js"; import { startOtherQSharpDiagnostics } from "./diagnostics.js"; import { removeDeprecatedCopilotInstructions } from "./gh-copilot/instructions.js"; import { registerLanguageModelTools } from "./gh-copilot/tools.js"; +import { initLearning } from "./learning/index.js"; import { activateLanguageService } from "./language-service/activate.js"; import { Logging, @@ -100,7 +101,8 @@ export async function activate( registerWebViewCommands(context); await initFileSystem(context); await initProjectCreator(context); - registerLanguageModelTools(context); + const learningService = initLearning(context); + registerLanguageModelTools(context, learningService); // fire-and-forget removeDeprecatedCopilotInstructions(context); From 3513606b1b2519fb7eb43db4497d02e303fa9c44 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 13 May 2026 19:23:16 +0000 Subject: [PATCH 05/26] slight cleanup of learning tools and ui panel --- source/vscode/src/gh-copilot/learningTools.ts | 2 +- .../src/learning/webview/webview-client.tsx | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 4a12e59c42..8fee41a00f 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -216,7 +216,7 @@ export class LearningTools { activityId?: string; }): Promise<{ state: SerializedLearningState }> { await this.ensureInitialized(); - this.service.goTo(input.unitId, input.activityId, "chat"); + this.service.goTo(input, "chat"); await this.showActivity(); return { state: this.serializeState() }; } diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 43fff40266..f27513221f 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -17,6 +17,7 @@ import type { ActionGroup, OverallProgress, ActivityContent, + ActivityLocation, SolutionCheckResult, OutputEvent, LearningState, @@ -121,7 +122,7 @@ function applyState( ): AppState { const p = prev.learning?.position; const n = learning.position; - if (p?.unitId !== n.unitId || p?.activityId !== n.activityId) { + if (!p || locationKey(p.location) !== locationKey(n.location)) { output = null; } return { learning, output, busy: false }; @@ -164,6 +165,10 @@ function messageToAction(msg: HostToWebviewMessage): AppAction { } } +function locationKey(loc: ActivityLocation): string { + return `${loc.courseId}__${loc.unitId}__${loc.activityId}`; +} + // ─── Components ─── function App() { @@ -224,9 +229,7 @@ function App() {
{output ? : null} @@ -585,9 +588,7 @@ function ProgressBar({ progress }: { progress: OverallProgress }) { {currentUnit ? ( <> - - {currentPosition!.unitTitle || currentPosition!.unitId} - + {currentUnit.title} {currentUnit.activities.map((act) => { const isCurrent = act.id === currentPosition!.activityId; @@ -599,9 +600,7 @@ function ProgressBar({ progress }: { progress: OverallProgress }) { ) : currentPosition && currentPosition.unitId ? ( - - {currentPosition.unitTitle || currentPosition.unitId} - + {currentPosition.unitId} ) : null} ); From 30c267437225751760cb269fc9431d5494c4db10 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 13 May 2026 21:12:30 +0000 Subject: [PATCH 06/26] render output properly --- .../src/learning/webview/webview-client.tsx | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index f27513221f..9552c1ced2 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -448,19 +448,86 @@ function EventList({ events }: { events: OutputEvent[] }) { return ( <> {events.map((event, i) => { - if (event.type === "message") { - return ( -
- {event.message} -
- ); + switch (event.type) { + case "message": + return ( +
+ {event.message} +
+ ); + case "dump": + return ; + case "matrix": + return ; + default: + return null; } - return null; })} ); } +function formatComplex(real: number, imag: number): string { + const r = `${real <= -0.00005 ? "\u2212" : ""}${Math.abs(real).toFixed(4)}`; + const i = `${imag <= -0.00005 ? "\u2212" : "+"}${Math.abs(imag).toFixed(4)}\u{1D456}`; + return `${r}${i}`; +} + +function StateTable({ state }: { state: Record }) { + const entries = Object.entries(state); + if (entries.length === 0) { + return
No qubits allocated
; + } + return ( + + + + + + + + + + + {entries.map(([basis, [real, imag]]) => { + const prob = (real * real + imag * imag) * 100; + const phase = Math.atan2(imag, real); + return ( + + + + + + + + ); + })} + +
Basis StateAmplitudeMeasurement ProbabilityPhase
{basis}{formatComplex(real, imag)} + + {prob.toFixed(4)}% + {phase.toFixed(4)}
+ ); +} + +function MatrixTable({ matrix }: { matrix: number[][][] }) { + return ( + + + {matrix.map((row, r) => ( + + {row.map(([real, imag], c) => ( + + ))} + + ))} + +
+ {formatComplex(real, imag)} +
+ ); +} + function ActionBar({ groups, busy: isBusy, From 3e587d94d972688a08422faa40461e8c91a2aa60 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 13 May 2026 21:43:36 +0000 Subject: [PATCH 07/26] the catalog --- source/vscode/src/learning/catalog.ts | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 source/vscode/src/learning/catalog.ts diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/catalog.ts new file mode 100644 index 0000000000..4f98ca7441 --- /dev/null +++ b/source/vscode/src/learning/catalog.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getAllKatas } from "qsharp-lang/katas-md"; +import { KATAS_COURSE_ID } from "./constants.js"; +import type { + CatalogUnit, + CatalogCourse, + CatalogSection, + CatalogExercise, +} from "./types.js"; + +/** + * Load the built-in Quantum Katas as a single `CatalogCourse`. + */ +export async function loadKatasCourse(): Promise { + const raw = await getAllKatas(); + const units: CatalogUnit[] = raw.map((kata) => ({ + id: kata.id, + title: kata.title, + sections: kata.sections.map((s) => { + if (s.type === "exercise") { + const solutionItem = s.explainedSolution.items.find( + (i) => i.type === "solution", + ); + const solutionExplanation = s.explainedSolution.items + .filter((i) => i.type === "text-content") + .map((i) => i.content) + .join("\n"); + return { + type: "exercise", + id: s.id, + title: s.title, + description: s.description.content, + placeholderCode: s.placeholderCode, + sourceIds: s.sourceIds, + hints: s.hints ?? [], + solutionCode: solutionItem?.code ?? "", + solutionExplanation, + } satisfies CatalogExercise; + } + + // Lesson — pre-split text around the first example (if any). + const items = s.items; + const exampleItem = items.find((i) => i.type === "example"); + + if (exampleItem) { + const exIdx = items.indexOf(exampleItem); + const before = items + .slice(0, exIdx) + .filter((i) => i.type === "text-content") + .map((i) => i.content) + .join("\n"); + const after = items + .slice(exIdx + 1) + .filter((i) => i.type === "text-content") + .map((i) => i.content) + .join("\n"); + return { + type: "lesson", + id: s.id, + title: s.title, + example: { id: exampleItem.id, code: exampleItem.code }, + contentBefore: before || undefined, + contentAfter: after || undefined, + }; + } + + // No example — merge all text items. + const content = items + .filter((i) => i.type === "text-content") + .map((i) => i.content) + .join("\n"); + return { + type: "lesson", + id: s.id, + title: s.title, + content, + }; + }), + })); + + return { id: KATAS_COURSE_ID, title: "Quantum Katas", units }; +} From 454a10651a6a5a2755817b9e7f2f2c3d0a541699 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 13 May 2026 21:43:59 +0000 Subject: [PATCH 08/26] code lens --- source/vscode/src/learning/codeLens.ts | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 source/vscode/src/learning/codeLens.ts diff --git a/source/vscode/src/learning/codeLens.ts b/source/vscode/src/learning/codeLens.ts new file mode 100644 index 0000000000..50cbb7d11a --- /dev/null +++ b/source/vscode/src/learning/codeLens.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { LEARNING_WORKSPACE_FOLDER } from "./constants.js"; + +/** + * Document selector that matches exercise files inside the well-known + * learning workspace folder. + */ +export const exerciseDocumentSelector: vscode.DocumentSelector = { + language: "qsharp", + pattern: `**/${LEARNING_WORKSPACE_FOLDER}/exercises/**/*.qs`, +}; + +/** + * CodeLens provider for learning exercise files. Shows a "Check Solution" + * action and a link to open the corresponding section in the Quantum Katas panel. + */ +export function createLearningCodeLensProvider(): vscode.CodeLensProvider { + return { + provideCodeLenses(): vscode.CodeLens[] { + // Place all lenses on line 0 — they appear as a row of links above the code. + const range = new vscode.Range(0, 0, 0, 0); + + return [ + new vscode.CodeLens(range, { + title: "$(pass) Check Solution", + command: "qsharp-vscode.learningCheckSolution", + tooltip: "Check your solution against the expected answer", + }), + new vscode.CodeLens(range, { + title: "$(discard) Reset Exercise", + command: "qsharp-vscode.learningResetExercise", + tooltip: "Reset the exercise to its original state", + }), + new vscode.CodeLens(range, { + title: "$(mortar-board) Show in Quantum Katas", + command: "qsharp-vscode.learningShowActivity", + tooltip: "Open the Quantum Katas panel for this exercise", + }), + ]; + }, + }; +} From ef4f5acc9232de971607a30450384dc25a55c367 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Thu, 14 May 2026 22:01:05 +0000 Subject: [PATCH 09/26] the commands --- source/vscode/src/learning/commands.ts | 140 +++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 source/vscode/src/learning/commands.ts diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts new file mode 100644 index 0000000000..d789d04114 --- /dev/null +++ b/source/vscode/src/learning/commands.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { KatasPanelManager } from "./panel.js"; +import type { LearningService } from "./service.js"; +import type { ActivityLocation } from "./types.js"; +import type { LearningProgressNode } from "./progressTreeView.js"; + +/** + * These are typically commands that will be wired up to the progress + * tree view or code lenses. + */ +export function registerLearningCommands( + context: vscode.ExtensionContext, + service: LearningService, +): void { + context.subscriptions.push( + vscode.commands.registerCommand( + "qsharp-vscode.learningShowActivity", + () => { + const manager = KatasPanelManager.getInstance( + context.extensionUri, + service, + ); + return manager.show(); + }, + ), + + // Code lens commands + + vscode.commands.registerCommand( + "qsharp-vscode.learningCheckSolution", + async () => { + const manager = KatasPanelManager.getInstance( + context.extensionUri, + service, + ); + await manager.checkAndShowResult(); + }, + ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningResetExercise", + async () => { + const confirmed = await vscode.window.showWarningMessage( + "Reset this exercise to the original placeholder code? Your current code will be lost.", + { modal: true }, + "Reset", + ); + if (confirmed !== "Reset") { + return; + } + + await service.resetExercise(); + vscode.window.showInformationMessage("Exercise has been reset."); + }, + ), + + // Progress tree commands + + vscode.commands.registerCommand( + "qsharp-vscode.learningRefresh", + async () => { + await service.refresh(); + }, + ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningContinue", + async () => { + // No position recorded yet — open chat with a generic start prompt. + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: "/qdk-learning Let's start the Quantum Katas.", + isPartialQuery: false, + }); + }, + ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningOpenActivity", + async (node: LearningProgressNode) => { + const location = nodeToLocation(node); + if (!location) { + return; + } + + service.goTo(location, "tree"); + const manager = KatasPanelManager.getInstance( + context.extensionUri, + service, + ); + await manager.show(); + }, + ), + + vscode.commands.registerCommand( + "qsharp-vscode.learningAskInChat", + async (node: LearningProgressNode) => { + const location = nodeToLocation(node); + if (!location) { + return; + } + + // Include #goto with precise IDs so the agent can call the + // tool without fuzzy matching. + const prompt = `/qdk-learning #goto ${location.unitId} ${location.activityId} — Go to this activity`; + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: prompt, + isPartialQuery: false, + }); + // Navigation telemetry will fire when the chat agent calls goTo via LM tools. + }, + ), + ); +} + +function nodeToLocation( + node: LearningProgressNode, +): ActivityLocation | undefined { + switch (node.kind) { + case "continue": + return node.location; + case "activity": + return { + courseId: node.courseId, + unitId: node.unitId, + activityId: node.activity.id, + }; + case "unit": { + const first = node.unit.activities[0]; + if (!first) return undefined; + return { + courseId: node.courseId, + unitId: node.unit.id, + activityId: first.id, + }; + } + } +} From c758b65b1218082ac2b73896f8976aa81bea3f18 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Thu, 14 May 2026 22:16:04 +0000 Subject: [PATCH 10/26] constants --- source/vscode/src/learning/constants.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 source/vscode/src/learning/constants.ts diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts new file mode 100644 index 0000000000..e8ee91078a --- /dev/null +++ b/source/vscode/src/learning/constants.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Well-known workspace folder name for katas exercise/example files. */ +export const LEARNING_WORKSPACE_FOLDER = "qdk-learning-ws"; + +/** Relative path form of {@link LEARNING_WORKSPACE_FOLDER}, for use in URI joins. */ +export const LEARNING_WORKSPACE_RELATIVE_PATH = `./${LEARNING_WORKSPACE_FOLDER}`; + +/** Well-known file that marks a workspace folder as a katas workspace. */ +export const LEARNING_FILE = "qdk-learning.json"; + +/** Context key set when a learning workspace is detected. */ +export const LEARNING_WORKSPACE_DETECTED_CONTEXT = + "qsharp-vscode.learningWorkspaceDetected"; + +/** Course ID for the built-in Quantum Katas. */ +export const KATAS_COURSE_ID = "katas"; From b8d465e66b360cf4baaa17340447b293610bce4c Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Fri, 15 May 2026 18:51:49 +0000 Subject: [PATCH 11/26] propagate errors nicely --- source/vscode/src/gh-copilot/learningTools.ts | 67 ++++++++++++++----- source/vscode/src/gh-copilot/tools.ts | 3 +- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 8fee41a00f..bc36bfecf2 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -84,7 +84,7 @@ export class LearningTools { * (after the user has already approved via {@link confirmInit}). */ private async ensureInitialized(): Promise { - const ok = await this.service.ensureInitialized({ createIfMissing: true }); + const ok = await this.service.tryInitialize({ createIfMissing: true }); if (!ok) { throw new CopilotToolError( "No workspace folder is open. Open a folder first, then try again.", @@ -169,8 +169,10 @@ export class LearningTools { state: SerializedLearningState; }> { await this.ensureInitialized(); - const r = this.service.getHintContext("chat"); - return { result: r.result, state: this.serializeState() }; + return this.invoke(() => { + const r = this.service.getHintContext("chat"); + return { result: r.result, state: this.serializeState() }; + }); } // ─── Navigation & actions (open the panel) ─── @@ -216,9 +218,11 @@ export class LearningTools { activityId?: string; }): Promise<{ state: SerializedLearningState }> { await this.ensureInitialized(); - this.service.goTo(input, "chat"); - await this.showActivity(); - return { state: this.serializeState() }; + return this.invoke(async () => { + this.service.goTo(input, "chat"); + await this.showActivity(); + return { state: this.serializeState() }; + }); } /** @@ -228,9 +232,11 @@ export class LearningTools { shots?: number; }): Promise<{ result: RunResult; state: SerializedLearningState }> { await this.ensureInitialized(); - const r = await this.service.run(input.shots ?? 1, "chat"); - await this.showActivity(); - return { result: r.result, state: this.serializeState() }; + return this.invoke(async () => { + const r = await this.service.run(input.shots ?? 1, "chat"); + await this.showActivity(); + return { result: r.result, state: this.serializeState() }; + }); } /** @@ -241,9 +247,11 @@ export class LearningTools { state: SerializedLearningState; }> { await this.ensureInitialized(); - const r = await this.service.checkSolution("chat"); - await this.showActivity(); - return { result: r.result, state: this.serializeState() }; + return this.invoke(async () => { + const r = await this.service.checkSolution("chat"); + await this.showActivity(); + return { result: r.result, state: this.serializeState() }; + }); } /** @@ -252,9 +260,11 @@ export class LearningTools { */ async resetExercise(): Promise<{ state: SerializedLearningState }> { await this.ensureInitialized(); - await this.service.resetExercise("chat"); - await this.showActivity(); - return { state: this.serializeState() }; + return this.invoke(async () => { + await this.service.resetExercise("chat"); + await this.showActivity(); + return { state: this.serializeState() }; + }); } /** @@ -265,13 +275,34 @@ export class LearningTools { state: SerializedLearningState; }> { await this.ensureInitialized(); - const result = this.service.getFullSolution("chat"); - await this.showActivity(); - return { result, state: this.serializeState() }; + return this.invoke(async () => { + const result = this.service.getFullSolution("chat"); + await this.showActivity(); + return { result, state: this.serializeState() }; + }); } // ─── Helpers ─── + /** + * Wrap a service call so that plain `Error`s thrown for expected + * conditions (wrong activity type, unknown unit ID, etc.) are + * surfaced to the model as {@link CopilotToolError}. + */ + private async invoke(fn: () => T | Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof CopilotToolError) { + throw e; + } + if (e instanceof Error) { + throw new CopilotToolError(e.message); + } + throw e; + } + } + private async showActivity(): Promise { await vscode.commands.executeCommand("qsharp-vscode.learningShowActivity"); } diff --git a/source/vscode/src/gh-copilot/tools.ts b/source/vscode/src/gh-copilot/tools.ts index 816dabd7b3..11ab813a24 100644 --- a/source/vscode/src/gh-copilot/tools.ts +++ b/source/vscode/src/gh-copilot/tools.ts @@ -250,7 +250,8 @@ async function invokeTool( // // If you need to include the error details for a specific error, catch // it and rethrow it as a CopilotToolError the relevant context. - resultText = "An error occurred."; + log.error(`Tool ${toolName} failed:`, e); + resultText = "An unexpected error occurred."; } } From 0bb45b0c9b4216121f114551218a95a2e1a36f3b Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Fri, 15 May 2026 18:52:49 +0000 Subject: [PATCH 12/26] panel and commands done --- source/vscode/src/learning/commands.ts | 26 +- source/vscode/src/learning/index.ts | 53 ++++ source/vscode/src/learning/panel.ts | 404 +++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 source/vscode/src/learning/index.ts create mode 100644 source/vscode/src/learning/panel.ts diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index d789d04114..2d3ef1a1e3 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as vscode from "vscode"; -import { KatasPanelManager } from "./panel.js"; +import { LessonPanelManager } from "./panel.js"; import type { LearningService } from "./service.js"; import type { ActivityLocation } from "./types.js"; import type { LearningProgressNode } from "./progressTreeView.js"; @@ -14,17 +14,11 @@ import type { LearningProgressNode } from "./progressTreeView.js"; export function registerLearningCommands( context: vscode.ExtensionContext, service: LearningService, + panelManager: LessonPanelManager, ): void { context.subscriptions.push( - vscode.commands.registerCommand( - "qsharp-vscode.learningShowActivity", - () => { - const manager = KatasPanelManager.getInstance( - context.extensionUri, - service, - ); - return manager.show(); - }, + vscode.commands.registerCommand("qsharp-vscode.learningShowActivity", () => + panelManager.show(), ), // Code lens commands @@ -32,11 +26,7 @@ export function registerLearningCommands( vscode.commands.registerCommand( "qsharp-vscode.learningCheckSolution", async () => { - const manager = KatasPanelManager.getInstance( - context.extensionUri, - service, - ); - await manager.checkAndShowResult(); + await panelManager.checkAndShowResult(); }, ), @@ -86,11 +76,7 @@ export function registerLearningCommands( } service.goTo(location, "tree"); - const manager = KatasPanelManager.getInstance( - context.extensionUri, - service, - ); - await manager.show(); + await panelManager.show(); }, ), diff --git a/source/vscode/src/learning/index.ts b/source/vscode/src/learning/index.ts new file mode 100644 index 0000000000..4c8cd7b2ea --- /dev/null +++ b/source/vscode/src/learning/index.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { + createLearningCodeLensProvider, + exerciseDocumentSelector, +} from "./codeLens.js"; +import { registerLearningCommands } from "./commands.js"; +import { LessonPanelManager, registerLessonPanelSerializer } from "./panel.js"; +import { registerLearningProgressView } from "./progressTreeView.js"; +import { LearningService } from "./service.js"; +import { registerLearningWelcomeView } from "./welcomeView.js"; + +export function initLearning( + context: vscode.ExtensionContext, +): LearningService { + const learningService = new LearningService(context.extensionUri); + const panelManager = new LessonPanelManager( + context.extensionUri, + learningService, + ); + context.subscriptions.push( + { dispose: () => learningService.dispose() }, + panelManager, + ); + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + exerciseDocumentSelector, + createLearningCodeLensProvider(), + ), + ); + registerLearningProgressView(context, learningService); + registerLearningWelcomeView(context, learningService); + registerLearningCommands(context, learningService, panelManager); + registerLessonPanelSerializer(context, panelManager); + return learningService; +} + +export type { + CurrentActivity, + HintContext, + OverallProgress, + RunResult, + SolutionCheckResult, + UnitSummary, +} from "./types.js"; +export { LEARNING_WORKSPACE_FOLDER } from "./constants.js"; +export { + detectLearningWorkspace, + LearningService, + resolveNewWorkspaceRoot, +} from "./service.js"; diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts new file mode 100644 index 0000000000..2eb9171022 --- /dev/null +++ b/source/vscode/src/learning/panel.ts @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Webview panel manager for the lesson panel. + * This panel displays the lesson content and the action buttons to interact with + * the learning feature. + */ + +import * as vscode from "vscode"; +import { qsharpExtensionId } from "../common.js"; +import { LEARNING_FILE } from "./constants.js"; +import type { LearningService, TelemetrySource } from "./service.js"; +import type { + HostToWebviewMessage, + ResultAction, + ResultPayload, + WebviewToHostMessage, +} from "./types.js"; + +/** + * Register the WebviewPanelSerializer so the Lesson panel persists across + * VS Code restarts. + */ +export function registerLessonPanelSerializer( + context: vscode.ExtensionContext, + manager: LessonPanelManager, +): void { + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer("qsharp-lesson", { + async deserializeWebviewPanel(panel: vscode.WebviewPanel) { + await manager.restore(panel); + }, + }), + ); +} + +export class LessonPanelManager { + private panel: vscode.WebviewPanel | undefined; + private ready = false; + private queuedMessages: unknown[] = []; + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly service: LearningService, + ) {} + + /** + * Show or create the Lesson panel. + */ + async show(): Promise { + if (this.panel) { + this.panel.reveal(vscode.ViewColumn.One); + return; + } + + const ok = await this.service.tryInitialize(); + if (!ok) { + vscode.window.showWarningMessage( + `No QDK Learning workspace detected. Open a folder containing ${LEARNING_FILE} first.`, + ); + return; + } + + this.panel = vscode.window.createWebviewPanel( + "qsharp-lesson", + "Lesson", + { viewColumn: vscode.ViewColumn.One, preserveFocus: false }, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, "out"), + vscode.Uri.joinPath(this.extensionUri, "resources"), + this.service.getKatasRoot(), + ], + }, + ); + + this.panel.iconPath = vscode.Uri.joinPath( + this.extensionUri, + "resources", + "mobius.svg", + ); + + // Generate and set HTML + this.panel.webview.html = this.getWebviewContent(this.panel.webview); + + this.attachPanel(); + } + + /** + * Restore a serialized Lesson panel after VS Code restarts. + * Re-initializes the service from disk before reconnecting the webview. + */ + async restore(panel: vscode.WebviewPanel): Promise { + const ok = await this.service.tryInitialize(); + if (!ok) { + // Workspace no longer available — dispose the stale panel. + panel.dispose(); + return; + } + + this.panel = panel; + + // Re-set HTML — webview resource URIs change across sessions. + this.panel.webview.html = this.getWebviewContent(this.panel.webview); + + this.attachPanel(); + } + + /** + * Wire up shared listeners on an already-created panel. + * Called by both show() (new panel) and restore() (deserialized panel). + */ + private attachPanel(): void { + if (!this.panel) { + return; + } + + this.panel.onDidDispose( + () => { + this.panel = undefined; + this.ready = false; + this.queuedMessages = []; + }, + undefined, + this.disposables, + ); + + // Listen for webview messages + this.panel.webview.onDidReceiveMessage( + (msg) => this.handleMessage(msg), + undefined, + this.disposables, + ); + + // Listen for state changes from the service. + this.disposables.push( + this.service.onDidChangeState(() => { + if (this.panel) { + this.sendState(); + this.openCurrentCodeEditor().catch(() => {}); + } + }), + ); + } + + dispose(): void { + this.panel?.dispose(); + // Close any lingering code editor tabs. + if (this.service.initialized) { + this.closeStaleEditorTabs(undefined).catch(() => {}); + } + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + private sendMessage(msg: HostToWebviewMessage): void { + if (!this.panel) { + return; + } + if (this.ready) { + this.panel.webview.postMessage(msg); + } else { + this.queuedMessages.push(msg); + } + } + + private sendState(): void { + if (!this.service.initialized) { + return; + } + this.sendMessage({ command: "state", state: this.service.getState() }); + } + + /** + * Show the panel and execute the "check solution" action, sending the + * result to the webview so it renders the same output as clicking the + * panel's own Check button. Returns whether the solution passed. + */ + async checkAndShowResult(): Promise { + await this.show(); + return this.checkSolutionAndSendResult(); + } + + /** + * If the current position is an exercise or example, open the + * corresponding .qs file in the secondary editor column. + * Closes any previously-opened code editor tabs that are no longer current. + */ + private async openCurrentCodeEditor(): Promise { + if (!this.service.initialized) { + return; + } + const fileUri = this.service.getCurrentCodeFileUri(); + + // Close stale editor tabs that don't match the current file. + await this.closeStaleEditorTabs(fileUri); + + if (fileUri) { + // Set a left/right two-column layout so the lesson panel stays in the + // first editor group and the code file opens beside it in the second. + await vscode.commands.executeCommand("vscode.setEditorLayout", { + orientation: 0, + groups: [{ size: 0.4 }, { size: 0.6 }], + }); + await vscode.commands.executeCommand("vscode.open", fileUri, { + viewColumn: vscode.ViewColumn.Two, + preview: false, + } satisfies vscode.TextDocumentShowOptions); + } + } + + /** + * Close any open editor tabs whose URI falls under the QDK Learning root + * that don't match {@link keepUri}. + * When {@link keepUri} is undefined, all code editor tabs are closed. + */ + private async closeStaleEditorTabs( + keepUri: vscode.Uri | undefined, + ): Promise { + const learningRoot = this.service.getKatasRoot().toString(); + const keepStr = keepUri?.toString(); + + const staleTabs: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof vscode.TabInputText) { + const tabUriStr = tab.input.uri.toString(); + if (tabUriStr.startsWith(learningRoot) && tabUriStr !== keepStr) { + staleTabs.push(tab); + } + } + } + } + if (staleTabs.length > 0) { + await vscode.window.tabGroups.close(staleTabs); + } + } + + private sendResult( + action: Action, + result: ResultPayload, + ): void { + if (!this.service.initialized) { + return; + } + this.sendMessage({ + command: "result", + action, + result, + state: this.service.getState(), + } as Extract); + } + + private sendError(message: string): void { + this.sendMessage({ command: "error", message }); + } + + private async handleMessage(msg: WebviewToHostMessage): Promise { + if (msg.command === "ready") { + this.ready = true; + for (const queued of this.queuedMessages) { + this.panel?.webview.postMessage(queued); + } + this.queuedMessages = []; + this.sendState(); + await this.openCurrentCodeEditor(); + return; + } + + if (msg.command === "openFile") { + const uri = vscode.Uri.parse(msg.uri); + await vscode.commands.executeCommand("vscode.open", uri, { + viewColumn: vscode.ViewColumn.Two, + preview: false, + } satisfies vscode.TextDocumentShowOptions); + return; + } + + if (msg.command === "openChat") { + const text = msg.text || "Give me a hint"; + await vscode.commands.executeCommand("workbench.action.chat.open", { + query: `/qdk-learning ${text}`, + }); + return; + } + + if (msg.command === "focusProgress") { + await vscode.commands.executeCommand("qsharp-vscode.learningTree.focus"); + return; + } + + if (msg.command === "action") { + await this.handleAction(msg.action); + } + } + + private async handleAction(action: string): Promise { + if (!this.service.initialized) { + return; + } + + try { + switch (action) { + case "next": { + const result = this.service.next("panel"); + this.sendResult("next", result); + break; + } + case "back": { + const result = this.service.previous("panel"); + this.sendResult("back", result); + break; + } + case "run": { + await this.executeRun(); + this.sendResult("run", {}); + this.service.sendActivityActionTelemetry("run", "panel"); + break; + } + case "check": { + await this.checkSolutionAndSendResult("panel"); + break; + } + default: + this.sendError(`Unknown action: ${action}`); + } + } catch (err: unknown) { + this.sendError(err instanceof Error ? err.message : String(err)); + } + } + + private async executeRun(): Promise { + if (!this.service.initialized) { + return; + } + + const pos = this.service.getPosition(); + if (pos.content.type !== "lesson-example") { + throw new Error("Current item cannot be run."); + } + + const fileUri = this.service.getExampleFileUri(); + this.service.markExampleRun(pos.content.id); + + await this.openCurrentCodeEditor(); + await vscode.commands.executeCommand( + `${qsharpExtensionId}.runProgram`, + fileUri, + ); + } + + private getWebviewContent(webview: vscode.Webview): string { + const extensionUri = this.extensionUri; + + function getUri(...parts: string[]): vscode.Uri { + return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, ...parts)); + } + + const webviewClientJsUri = getUri( + "out", + "learning", + "webview", + "webview-client.js", + ); + const cssUri = getUri("out", "learning", "webview", "webview.css"); + const katexCssUri = getUri("out", "katex", "katex.min.css"); + const codiconCssUri = getUri("out", "katex", "codicon.css"); + const mobiusUri = getUri("resources", "mobius.svg"); + + return /*html*/ ` + + + + Lesson + + + + + + + +`; + } + + private async checkSolutionAndSendResult( + source?: TelemetrySource, + ): Promise { + const { result, state } = await this.service.checkSolution(source); + this.sendMessage({ + command: "result", + action: "check", + result, + state, + }); + return result.passed; + } +} From 450f444125df2a3a3ae87e5b41c25878a57e199d Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Fri, 15 May 2026 18:58:26 +0000 Subject: [PATCH 13/26] tsconfig done --- source/vscode/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/tsconfig.json b/source/vscode/tsconfig.json index 6fac7b5195..8c29554ec0 100644 --- a/source/vscode/tsconfig.json +++ b/source/vscode/tsconfig.json @@ -13,5 +13,5 @@ "strict": true, "skipLibCheck": true }, - "exclude": ["test", "src/webview", "src/copilot/webview"] + "exclude": ["test", "src/webview", "src/learning/webview"] } From 4c63b0d05b91219941d55c123d519112896bd73d Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 05:27:28 +0000 Subject: [PATCH 14/26] reviewed corrections --- .gitignore | 2 ++ source/vscode/agents/qdk-learning.agent.md | 11 ++++----- source/vscode/build.mjs | 4 +++- source/vscode/src/gh-copilot/learningTools.ts | 24 +++++++++---------- source/vscode/src/learning/panel.ts | 13 +++++----- .../src/learning/webview/webview-client.tsx | 1 + 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 158360abbc..83ba81dc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,7 @@ __pycache__/ .idea/ *.so samples/scratch/ +samples/qdk-learning-ws/ +samples/qdk-learning.json *.pyd /python_doc/ diff --git a/source/vscode/agents/qdk-learning.agent.md b/source/vscode/agents/qdk-learning.agent.md index c6a395b679..4142536b38 100644 --- a/source/vscode/agents/qdk-learning.agent.md +++ b/source/vscode/agents/qdk-learning.agent.md @@ -83,13 +83,12 @@ Render tool results in chat. Keep responses short and tutor-like. ### Hint Strategy -When the user asks for a hint (or clicks the Hint button): +Call `hint` and `read-code` together. The response contains `hints` (short nudges, easiest→hardest) and `solutionExplanation` (deeper walkthrough). -1. Call `hint` and `read-code` together. The hint tool returns `hints` (short author-written nudges) and `solutionExplanation` (deeper prose walkthrough). The code shows what the user has tried so far. -2. Reveal hints **one at a time** ("Hint 1/N"). If the user's code already satisfies a hint, acknowledge briefly and skip ahead to the next applicable one. -3. On subsequent "another hint" requests, continue through the list — don't re-call the tool. -4. When author hints are exhausted, **paraphrase** `solutionExplanation` as a deeper nudge (don't dump verbatim). -5. If the tool returns no hints, generate a pedagogical hint yourself from the exercise description and Q# knowledge. +- Reveal `hints` one at a time ("Hint 1/N"). Skip any the user's code already satisfies. +- On follow-up requests, continue through the list — don't re-call the tool. +- After all hints: paraphrase `solutionExplanation` as a deeper nudge (never dump verbatim). +- No hints at all: generate one yourself from the exercise description and Q# knowledge. ### After a Passing Check diff --git a/source/vscode/build.mjs b/source/vscode/build.mjs index 986ccb6d25..7fee3349ee 100644 --- a/source/vscode/build.mjs +++ b/source/vscode/build.mjs @@ -29,10 +29,12 @@ const platformBuildOptions = { ui: { ...commonBuildOptions, platform: "browser", - outdir: join(thisDir, "out", "webview"), + outbase: join(thisDir, "src"), + outdir: join(thisDir, "out"), entryPoints: [ join(thisDir, "src", "webview/webview.tsx"), join(thisDir, "src", "webview/editor.tsx"), + join(thisDir, "src", "learning/webview/webview-client.tsx"), ], define: { "import.meta.url": "undefined", diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index bc36bfecf2..4eb8c6c8da 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -153,11 +153,11 @@ export class LearningTools { }> { await this.ensureInitialized(); const uri = this.getCurrentFileUri(); - const pos = this.service.getPosition(); - const code = - pos.content.type === "exercise" - ? await this.service.readUserCode() - : new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + const isExercise = + this.service.getCurrentActivity().content.type === "exercise"; + const code = isExercise + ? await this.service.readUserCode() + : new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); return { code, filePath: uri.fsPath, state: this.serializeState() }; } @@ -308,15 +308,13 @@ export class LearningTools { } private getCurrentFileUri(): vscode.Uri { - const pos = this.service.getPosition(); - if (pos.content.type === "exercise") { - return this.service.getExerciseFileUri(); - } else if (pos.content.type === "lesson-example") { - return this.service.getExampleFileUri(); + const uri = this.service.getCurrentCodeFileUri(); + if (!uri) { + throw new CopilotToolError( + "Current activity is not an exercise or example — there is no code to read.", + ); } - throw new CopilotToolError( - "Current activity is not an exercise or example — there is no code to read.", - ); + return uri; } /** diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index 2eb9171022..a2f232faae 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -10,7 +10,8 @@ import * as vscode from "vscode"; import { qsharpExtensionId } from "../common.js"; import { LEARNING_FILE } from "./constants.js"; -import type { LearningService, TelemetrySource } from "./service.js"; +import type { LearningService } from "./service.js"; +import type { TelemetrySource } from "./types.js"; import type { HostToWebviewMessage, ResultAction, @@ -74,7 +75,7 @@ export class LessonPanelManager { localResourceRoots: [ vscode.Uri.joinPath(this.extensionUri, "out"), vscode.Uri.joinPath(this.extensionUri, "resources"), - this.service.getKatasRoot(), + this.service.learningContentRoot, ], }, ); @@ -224,7 +225,7 @@ export class LessonPanelManager { private async closeStaleEditorTabs( keepUri: vscode.Uri | undefined, ): Promise { - const learningRoot = this.service.getKatasRoot().toString(); + const learningRoot = this.service.learningContentRoot.toString(); const keepStr = keepUri?.toString(); const staleTabs: vscode.Tab[] = []; @@ -341,13 +342,13 @@ export class LessonPanelManager { return; } - const pos = this.service.getPosition(); + const pos = this.service.getCurrentActivity(); if (pos.content.type !== "lesson-example") { throw new Error("Current item cannot be run."); } const fileUri = this.service.getExampleFileUri(); - this.service.markExampleRun(pos.content.id); + await this.service.markExampleRun(); await this.openCurrentCodeEditor(); await vscode.commands.executeCommand( @@ -369,7 +370,7 @@ export class LessonPanelManager { "webview", "webview-client.js", ); - const cssUri = getUri("out", "learning", "webview", "webview.css"); + const cssUri = getUri("out", "learning", "webview", "webview-client.css"); const katexCssUri = getUri("out", "katex", "katex.min.css"); const codiconCssUri = getUri("out", "katex", "codicon.css"); const mobiusUri = getUri("resources", "mobius.svg"); diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 9552c1ced2..81cbfa4275 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -8,6 +8,7 @@ const vscodeApi: WebviewApi = acquireVsCodeApi(); import { render } from "preact"; import { useEffect, useReducer, useRef } from "preact/hooks"; import { Markdown, setRenderer } from "qsharp-lang/ux"; +import "./webview.css"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - there are no types for this import mk from "@vscode/markdown-it-katex"; From 9ca962ccc42b5dba53927c896b8728503c8ecf8d Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 05:27:59 +0000 Subject: [PATCH 15/26] progress tree --- .../vscode/src/learning/progressTreeView.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 source/vscode/src/learning/progressTreeView.ts diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts new file mode 100644 index 0000000000..8a7c638c63 --- /dev/null +++ b/source/vscode/src/learning/progressTreeView.ts @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import type { + ActivityLocation, + UnitProgress, + OverallProgress, + ActivityProgress, +} from "./types.js"; +import type { LearningService } from "./service.js"; + +/** + * Wire up the QDK Learning progress panel, a `TreeView` of Unit → Activity + * nodes with action buttons and progress indicators. + */ +export function registerLearningProgressView( + context: vscode.ExtensionContext, + service: LearningService, +): void { + const treeDataProvider = new LearningProgressTreeProvider(); + const treeView = vscode.window.createTreeView("qsharp-vscode.learningTree", { + treeDataProvider, + showCollapseAll: true, + }); + context.subscriptions.push( + service.onDidChangeProgress((snapshot) => { + treeDataProvider.update(snapshot); + treeView.message = buildTreeMessage(snapshot); + }), + treeView.onDidChangeVisibility((e) => { + if (e.visible) { + service.tryInitialize(); + } + }), + treeView, + treeDataProvider, + ); + + // If the panel is already visible at registration time (e.g. VS Code + // restored the activity bar on startup), initialize immediately. + if (treeView.visible) { + service.tryInitialize(); + } +} + +class LearningProgressTreeProvider implements vscode.TreeDataProvider { + private readonly emitter = new vscode.EventEmitter< + LearningProgressNode | undefined + >(); + readonly onDidChangeTreeData = this.emitter.event; + + private snapshot: OverallProgress | undefined; + + update(snapshot: OverallProgress | undefined): void { + this.snapshot = snapshot; + this.emitter.fire(undefined); + } + + getTreeItem(node: LearningProgressNode): vscode.TreeItem { + if (node.kind === "continue") { + const item = new vscode.TreeItem( + `Up next: ${node.activityTitle}`, + vscode.TreeItemCollapsibleState.None, + ); + item.description = node.unitTitle; + item.iconPath = iconContinue; + item.contextValue = "continue"; + item.tooltip = `Continue learning — ${node.unitTitle}: ${node.activityTitle}`; + item.id = "continue"; + return item; + } + + if (node.kind === "unit") { + const { unit, isCurrent } = node; + const item = new vscode.TreeItem( + unit.title, + isCurrent + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + ); + item.description = + unit.completed > 0 && unit.completed < unit.total + ? `${unit.completed}/${unit.total}` + : undefined; + item.iconPath = unitIcon(unit); + item.contextValue = "unit"; + item.tooltip = `${unit.title} — ${unit.completed}/${unit.total} activities complete`; + // Vary the id by `isCurrent` so VS Code sees a new node when the active + // unit changes and applies the collapsibleState we set above. + item.id = isCurrent ? `unit:${unit.id}:current` : `unit:${unit.id}`; + return item; + } + + const { unitId, activity, isCurrent } = node; + const item = new vscode.TreeItem( + activity.title, + vscode.TreeItemCollapsibleState.None, + ); + item.iconPath = activityIcon(activity, isCurrent); + item.contextValue = activity.type; + item.tooltip = activity.isComplete + ? `Completed${activity.completedAt ? ` \u00b7 ${new Date(activity.completedAt).toLocaleString()}` : ""}` + : activity.type === "exercise" + ? "Exercise \u2014 click the action icon to open" + : "Lesson \u2014 click the action icon to open"; + item.id = `activity:${unitId}:${activity.id}`; + return item; + } + + getChildren(node?: LearningProgressNode): LearningProgressNode[] { + const snap = this.snapshot; + if (!snap) { + return []; + } + + if (!node) { + const children: LearningProgressNode[] = []; + const { courseId, unitId, activityId } = snap.currentPosition; + + const unit = snap.units.find((u) => u.id === unitId); + const activity = unit?.activities.find((a) => a.id === activityId); + if (unit && activity) { + children.push({ + kind: "continue", + location: { courseId, unitId: unit.id, activityId: activity.id }, + unitTitle: unit.title, + activityTitle: activity.title, + }); + } + + for (const u of snap.units) { + children.push({ + kind: "unit", + courseId, + unit: u, + isCurrent: u.id === unitId, + }); + } + + return children; + } + + if (node.kind === "unit") { + const { unitId, activityId } = snap.currentPosition; + return node.unit.activities.map((activity) => ({ + kind: "activity", + courseId: node.courseId, + unitId: node.unit.id, + unitTitle: node.unit.title, + activity, + isCurrent: node.unit.id === unitId && activity.id === activityId, + })); + } + + return []; + } + + dispose(): void { + this.emitter.dispose(); + } +} + +/** Builds the italic summary shown at the top of the tree view. */ +function buildTreeMessage( + snapshot: OverallProgress | undefined, +): string | undefined { + if (!snapshot) { + return undefined; + } + + const units = snapshot.units; + if (units.length === 0) { + return undefined; + } + + const completedUnits = units.filter( + (u) => u.total > 0 && u.completed === u.total, + ).length; + + const ratio = completedUnits / units.length; + + let encouragement: string; + if (ratio >= 1) { + return `All ${units.length} units complete — nicely done!`; + } else if (ratio === 0 && snapshot.stats.completedActivities === 0) { + encouragement = "let's get started!"; + } else if (ratio < 0.25) { + encouragement = "great start!"; + } else if (ratio < 0.5) { + encouragement = "making progress!"; + } else if (ratio < 0.75) { + encouragement = "over halfway there!"; + } else { + encouragement = "almost there!"; + } + + return `${completedUnits}/${units.length} units complete — ${encouragement}`; +} + +/** Discriminated union for the three kinds of tree nodes. */ +export type LearningProgressNode = + | { + /** Pinned "Up next" shortcut at the top of the tree. */ + kind: "continue"; + location: ActivityLocation; + unitTitle: string; + activityTitle: string; + } + | { + /** Unit node (expandable). */ + kind: "unit"; + courseId: string; + unit: UnitProgress; + isCurrent: boolean; + } + | { + /** Leaf node representing a lesson or exercise within a unit. */ + kind: "activity"; + courseId: string; + unitId: string; + unitTitle: string; + activity: ActivityProgress; + isCurrent: boolean; + }; + +// ─── Tree node icons ─── + +const iconContinue = new vscode.ThemeIcon( + "sparkle", + new vscode.ThemeColor("charts.blue"), +); +const iconPassed = new vscode.ThemeIcon( + "pass", + new vscode.ThemeColor("testing.iconPassed"), +); +const iconCurrent = new vscode.ThemeIcon( + "circle-filled", + new vscode.ThemeColor("charts.blue"), +); +const iconIncomplete = new vscode.ThemeIcon("circle-large-outline"); +const iconInProgress = new vscode.ThemeIcon( + "record", + new vscode.ThemeColor("charts.blue"), +); + +function activityIcon( + a: ActivityProgress, + isCurrent: boolean, +): vscode.ThemeIcon { + if (a.isComplete) { + return iconPassed; + } + if (isCurrent) { + return iconCurrent; + } + return iconIncomplete; +} + +function unitIcon(u: UnitProgress): vscode.ThemeIcon { + if (u.total > 0 && u.completed === u.total) { + return iconPassed; + } + if (u.completed > 0) { + return iconInProgress; + } + return iconIncomplete; +} From 6284515c9c4c59c5d87fd7ca02d7c0016859850b Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 07:29:55 +0000 Subject: [PATCH 16/26] code done --- source/vscode/src/learning/catalog.ts | 4 +- source/vscode/src/learning/service.ts | 1163 +++++++++++++++++ source/vscode/src/learning/types.d.ts | 278 ++++ .../src/learning/webview/webview-client.tsx | 87 +- source/vscode/src/learning/welcomeView.ts | 132 ++ source/vscode/src/run.ts | 2 +- source/vscode/src/telemetry.ts | 24 + 7 files changed, 1607 insertions(+), 83 deletions(-) create mode 100644 source/vscode/src/learning/service.ts create mode 100644 source/vscode/src/learning/types.d.ts create mode 100644 source/vscode/src/learning/welcomeView.ts diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/catalog.ts index 4f98ca7441..f64dfea9be 100644 --- a/source/vscode/src/learning/catalog.ts +++ b/source/vscode/src/learning/catalog.ts @@ -6,7 +6,7 @@ import { KATAS_COURSE_ID } from "./constants.js"; import type { CatalogUnit, CatalogCourse, - CatalogSection, + CatalogActivity, CatalogExercise, } from "./types.js"; @@ -18,7 +18,7 @@ export async function loadKatasCourse(): Promise { const units: CatalogUnit[] = raw.map((kata) => ({ id: kata.id, title: kata.title, - sections: kata.sections.map((s) => { + activities: kata.sections.map((s) => { if (s.type === "exercise") { const solutionItem = s.explainedSolution.items.find( (i) => i.type === "solution", diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts new file mode 100644 index 0000000000..ca7b605116 --- /dev/null +++ b/source/vscode/src/learning/service.ts @@ -0,0 +1,1163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getExerciseSources } from "qsharp-lang/katas-md"; +import * as vscode from "vscode"; +import { FullProgramConfig, getProgramForDocument } from "../programConfig.js"; +import { ProgramRunStatus, runProgram } from "../run.js"; +import { EventType, sendTelemetryEvent } from "../telemetry.js"; +import { loadKatasCourse } from "./catalog.js"; +import { + LEARNING_FILE, + LEARNING_WORKSPACE_DETECTED_CONTEXT, + LEARNING_WORKSPACE_FOLDER, + LEARNING_WORKSPACE_RELATIVE_PATH, +} from "./constants.js"; +import type { + ActionGroup, + ActivityContent, + ActivityLocation, + ActivityProgress, + CatalogCourse, + CatalogExercise, + CatalogActivity, + CatalogUnit, + CurrentActivity, + ExerciseContent, + HintContext, + LearningState, + LessonExampleContent, + LessonTextContent, + NavigationResult, + OverallProgress, + PrimaryAction, + ProgressFileData, + RunResult, + SolutionCheckResult, + TelemetrySource, + UnitProgress, + UnitSummary, +} from "./types.js"; + +/** Returns the first open workspace folder URI, or `undefined`. */ +export function resolveNewWorkspaceRoot(): vscode.Uri | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return undefined; + } + return folders[0].uri; +} + +/** + * Detect an existing learning workspace by scanning all open workspace + * folders for a `qdk-learning.json` file. + * + * Returns `undefined` if no learning workspace can be found. + */ +export async function detectLearningWorkspace(): Promise< + LearningWorkspaceInfo | undefined +> { + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const learningFile = vscode.Uri.joinPath(folder.uri, LEARNING_FILE); + try { + await vscode.workspace.fs.stat(learningFile); + } catch { + continue; + } + + const learningContentRoot = vscode.Uri.joinPath( + folder.uri, + LEARNING_WORKSPACE_RELATIVE_PATH, + ); + return { + workspaceRoot: folder.uri, + learningContentRoot, + learningFile, + }; + } + + return undefined; +} + +interface LearningWorkspaceInfo { + /** The workspace folder that contains `qdk-learning.json`. */ + workspaceRoot: vscode.Uri; + /** The learning content folder, resolved from the well-known folder name. */ + learningContentRoot: vscode.Uri; + /** Path to `qdk-learning.json`. */ + learningFile: vscode.Uri; +} + +/** All state that exists only while a learning workspace is loaded. */ +interface WorkspaceState extends LearningWorkspaceInfo { + /** Currently, only a single course is supported. */ + catalog: CatalogCourse; + progressData: ProgressFileData; +} + +export class LearningService { + private workspace: WorkspaceState | undefined; + + private readonly _onDidChangeState = new vscode.EventEmitter(); + readonly onDidChangeState = this._onDidChangeState.event; + + private readonly _onDidChangeProgress = new vscode.EventEmitter< + OverallProgress | undefined + >(); + readonly onDidChangeProgress = this._onDidChangeProgress.event; + + private _lastSnapshot: OverallProgress | undefined; + private _progressFileWatcher: vscode.FileSystemWatcher | undefined; + private _writingProgress = false; + private _initPromise: Promise | undefined; + + constructor(private readonly extensionUri: vscode.Uri) {} + + get initialized(): boolean { + return this.workspace !== undefined; + } + + get learningContentRoot(): vscode.Uri { + return this.requireWorkspace().learningContentRoot; + } + + /** + * Try to initialize the service. Returns `true` when ready, `false` + * when no learning workspace could be found (or created). + * + * Detects an existing `qdk-learning.json` on disk. When + * `createIfMissing` is set, bootstraps a new workspace in the first + * open folder instead of returning `false`. + * + * Safe to call multiple times — concurrent calls are coalesced and + * subsequent calls after success return immediately. + */ + async tryInitialize(options?: { + createIfMissing?: boolean; + }): Promise { + if (this.workspace) { + return true; + } + + // If there's an in-flight attempt, wait for it first. + if (this._initPromise) { + const result = await this._initPromise; + // If init succeeded, or the caller doesn't need creation, we're done. + if (result || !options?.createIfMissing) { + return result; + } + if (this.workspace) { + return true; + } + // The in-flight attempt didn't create — fall through to retry. + } + + this._initPromise = this.detectAndLoadWorkspace(options).finally(() => { + this._initPromise = undefined; + }); + return await this._initPromise; + } + + dispose(): void { + if (this.workspace) { + this.saveProgress().catch(() => {}); + } + this._onDidChangeState.dispose(); + this._onDidChangeProgress.dispose(); + this._progressFileWatcher?.dispose(); + } + + /** Force a fresh progress reload from disk. */ + async refresh(): Promise { + if (this.workspace) { + await this.reloadProgress(); + } + } + + /** The current position in the learning workspace. */ + get position(): ActivityLocation { + return this.requireWorkspace().progressData.position; + } + + /** Resolves the current position into a rich object with + * titles and content for rendering. */ + getCurrentActivity(): CurrentActivity { + const pos = this.position; + const kata = this.findUnit(pos.unitId); + const activity = kata.activities.find((s) => s.id === pos.activityId)!; + return { + location: pos, + unitTitle: kata.title, + activityTitle: activity.title, + content: this.resolveActivityContent(pos, kata, activity), + }; + } + + /** Full snapshot of position, available actions, and progress. + * The payload sent to the webview. */ + getState(): LearningState { + return { + position: this.getCurrentActivity(), + actions: this.getAvailableActions(), + progress: this.getProgress(), + }; + } + + next(source: TelemetrySource): NavigationResult { + const ws = this.requireWorkspace(); + const currentPos = ws.progressData.position; + const nextPos = this.nextActivity(currentPos); + if (!nextPos) { + return { moved: false }; + } + + // Auto-mark lesson activities complete when moving forward + const oldKata = this.findUnit(currentPos.unitId); + const oldActivity = oldKata.activities.find( + (s) => s.id === currentPos.activityId, + ); + if (oldActivity?.type === "lesson") { + this.markComplete(currentPos); + } + + ws.progressData.position = nextPos; + this.saveProgress().catch(() => {}); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + + previous(source: TelemetrySource): NavigationResult { + const ws = this.requireWorkspace(); + const prevPos = this.previousActivity(ws.progressData.position); + if (!prevPos) { + return { moved: false }; + } + + ws.progressData.position = prevPos; + this.saveProgress().catch(() => {}); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + + goTo( + location: { unitId: string; activityId?: string }, + source?: TelemetrySource, + ): LearningState { + const ws = this.requireWorkspace(); + const unit = ws.catalog.units.find((u) => u.id === location.unitId); + if (!unit || unit.activities.length === 0) { + throw new Error(`Position not found: ${location.unitId}`); + } + const activity = location.activityId + ? unit.activities.find((s) => s.id === location.activityId) + : unit.activities[0]; + if (!activity) { + throw new Error( + `Position not found: ${location.unitId} activity ${location.activityId}`, + ); + } + ws.progressData.position = { + courseId: ws.catalog.id, + unitId: location.unitId, + activityId: activity.id, + }; + this.saveProgress().catch(() => {}); + const state = this.getState(); + this._onDidChangeState.fire(state); + if (source) { + this.sendActivityActionTelemetry("navigate", source); + } + return state; + } + + listUnits(): UnitSummary[] { + const ws = this.requireWorkspace(); + let foundFirstIncomplete = false; + + return ws.catalog.units.map((kata) => { + const activityCount = kata.activities.length; + let completedCount = 0; + for (const activity of kata.activities) { + if ( + this.findCompletion({ + courseId: ws.catalog.id, + unitId: kata.id, + activityId: activity.id, + }) + ) { + completedCount++; + } + } + + let firstIncomplete = false; + if (completedCount < activityCount && !foundFirstIncomplete) { + foundFirstIncomplete = true; + firstIncomplete = true; + } + + return { + id: kata.id, + title: kata.title, + activityCount, + completedCount, + firstIncomplete, + }; + }); + } + + getProgress(): OverallProgress { + const ws = this.requireWorkspace(); + let totalActivities = 0; + let completedActivities = 0; + + const units: UnitProgress[] = ws.catalog.units.map((k) => { + const activities: ActivityProgress[] = k.activities.map((s) => { + const completion = this.findCompletion({ + courseId: ws.catalog.id, + unitId: k.id, + activityId: s.id, + }); + return { + id: s.id, + title: s.title, + type: s.type, + isComplete: completion != null, + completedAt: completion?.completedAt, + }; + }); + const completed = activities.filter((a) => a.isComplete).length; + totalActivities += activities.length; + completedActivities += completed; + return { + id: k.id, + title: k.title, + total: activities.length, + completed, + activities, + }; + }); + + return { + units, + currentPosition: ws.progressData.position, + stats: { totalActivities, completedActivities }, + }; + } + + /** Returns hints and solution explanation for the current exercise, or `null` if none exist. */ + getHintContext(source?: TelemetrySource): { + result: HintContext | null; + state: LearningState; + } { + const exercise = this.resolveExercise(); + + const hints = exercise.hints; + const solutionExplanation = exercise.solutionExplanation; + + if (hints.length === 0 && solutionExplanation.length === 0) { + return { result: null, state: this.getState() }; + } + + if (source) { + this.sendActivityActionTelemetry("hint", source); + } + + return { + result: { hints, solutionExplanation }, + state: this.getState(), + }; + } + + getFullSolution(source?: TelemetrySource): string { + const exercise = this.resolveExercise(); + if (source) { + this.sendActivityActionTelemetry("solution", source); + } + return exercise.solutionCode; + } + + getExerciseFileUri(): vscode.Uri { + const exercise = this.resolveExercise(); + return vscode.Uri.joinPath( + this.requireWorkspace().learningContentRoot, + "exercises", + this.position.unitId, + `${exercise.id}.qs`, + ); + } + + getExampleFileUri(): vscode.Uri { + const { unit, activity } = this.findCurrentActivity(); + if (activity.type !== "lesson" || !activity.example) { + throw new Error("Current activity is not an example"); + } + return vscode.Uri.joinPath( + this.requireWorkspace().learningContentRoot, + "examples", + unit.id, + `${activity.example.id}.qs`, + ); + } + + async readUserCode(): Promise { + const uri = this.getExerciseFileUri(); + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); + } + + async markExampleRun(): Promise { + const location = this.requireWorkspace().progressData.position; + this.markComplete(location); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + } + + getCurrentCodeFileUri(): vscode.Uri | undefined { + const { activity } = this.findCurrentActivity(); + if (activity.type === "exercise") { + return this.getExerciseFileUri(); + } + if (activity.type === "lesson" && activity.example) { + return this.getExampleFileUri(); + } + return undefined; + } + + /** + * Reset the current exercise file to the original placeholder code + * and clear its completion status. + */ + async resetExercise(source?: TelemetrySource): Promise { + const exercise = this.resolveExercise(); + const uri = this.getExerciseFileUri(); + await vscode.workspace.fs.writeFile( + uri, + new TextEncoder().encode(exercise.placeholderCode), + ); + this.markIncomplete(this.requireWorkspace().progressData.position); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + if (source) { + this.sendActivityActionTelemetry("reset", source); + } + } + + async run( + shots: number = 1, + source?: TelemetrySource, + ): Promise<{ result: RunResult; state: LearningState }> { + const { activity } = this.findCurrentActivity(); + if (activity.type === "exercise") { + throw new Error("Exercises cannot be run. Use checkSolution() instead."); + } + const fileUri = this.getCurrentCodeFileUri(); + if (!fileUri) { + throw new Error("Current activity cannot be run."); + } + + if (activity.type === "lesson" && activity.example) { + await this.markExampleRun(); + } + + if (source) { + this.sendActivityActionTelemetry("run", source); + } + + const doc = await vscode.workspace.openTextDocument(fileUri); + const programResult = await getProgramForDocument(doc); + if (!programResult.success) { + return { + result: { success: false, messages: [], error: programResult.errorMsg }, + state: this.getState(), + }; + } + + const result = await this.executeProgram(programResult.programConfig, { + shots, + }); + return { result, state: this.getState() }; + } + + async checkSolution(source?: TelemetrySource): Promise<{ + result: SolutionCheckResult; + state: LearningState; + }> { + const { activity } = this.findCurrentActivity(); + if (activity.type !== "exercise") { + throw new Error("Current activity is not an exercise."); + } + + if (source) { + this.sendActivityActionTelemetry("check", source); + } + + const exercise = this.resolveExercise(); + const userCode = await this.readUserCode(); + const exerciseSources = await getExerciseSources( + // CatalogExercise is structurally incompatible with Exercise (different + // description/solution shapes), but getExerciseSources only reads sourceIds. + exercise as any, + ); + + // Build a synthetic program config combining the user's solution + // with the exercise verification sources from the katas bundle. + const programConfig: FullProgramConfig = { + projectName: "exercise-check", + projectUri: "", + packageGraphSources: { + root: { + sources: [ + ["solution", userCode], + ...exerciseSources.map( + (code, i) => [String(i), code] as [string, string], + ), + ], + languageFeatures: [], + dependencies: {}, + packageType: "exe", + }, + packages: {}, + hasManifest: false, + }, + lints: [], + errors: [], + projectType: "qsharp", + profile: "unrestricted", + }; + + const execResult = await this.executeProgram(programConfig, { + entry: "Kata.Verification.CheckSolution()", + }); + + const passed = execResult.success && execResult.result === "true"; + + if (passed) { + await this.markExerciseComplete( + this.requireWorkspace().progressData.position, + ); + } + + return { + result: { + passed, + messages: execResult.messages, + error: passed + ? undefined + : (execResult.error ?? + (execResult.messages.length === 0 + ? "Solution check failed." + : undefined)), + }, + state: this.getState(), + }; + } + + sendActivityActionTelemetry( + action: "navigate" | "run" | "check" | "hint" | "solution" | "reset", + source: TelemetrySource, + ): void { + const activityType = + this.findCurrentActivity().activity.type === "exercise" + ? "exercise" + : "lesson"; + sendTelemetryEvent( + EventType.LearningActivityAction, + { action, activityType, source }, + {}, + ); + } + + // ─── Private: execution ─── + + private async executeProgram( + programConfig: FullProgramConfig, + options?: { entry?: string; shots?: number }, + ): Promise { + const messages: string[] = []; + + try { + const runResult = await runProgram(this.extensionUri, programConfig, { + entry: options?.entry, + shots: options?.shots ?? 1, + onConsoleOut: (msg) => { + messages.push(msg); + }, + }); + + if (runResult.status === ProgramRunStatus.CompilationErrors) { + return { + success: false, + messages, + error: + runResult.errors + .map((e) => e.diagnostic?.message ?? String(e)) + .join("\n") || "Compilation failed.", + }; + } + + const success = runResult.status === ProgramRunStatus.AllShotsDone; + let result: string | undefined; + if (success) { + const shot = runResult.shotResults.at(-1); + if (shot && !Array.isArray(shot) && shot.success) { + result = shot.result; + } + } + + return { + success, + messages, + result, + error: success + ? undefined + : `Program ended with status: ${runResult.status}.`, + }; + } catch (err: unknown) { + return { + success: false, + messages: [], + error: err instanceof Error ? err.message : String(err), + }; + } + } + + // ─── Private: initialization ─── + + private async detectAndLoadWorkspace(options?: { + createIfMissing?: boolean; + }): Promise { + const detected = await detectLearningWorkspace(); + + if (detected) { + await this.loadWorkspace( + detected.workspaceRoot, + detected.learningContentRoot, + ); + this.startWatcher(); + sendTelemetryEvent( + EventType.LearningSessionStarted, + { isFirstTime: "false" }, + {}, + ); + return true; + } + + if (!options?.createIfMissing) { + return false; + } + + // No existing workspace — bootstrap in the first open folder. + const workspaceRoot = resolveNewWorkspaceRoot(); + if (!workspaceRoot) { + return false; + } + const katasRoot = vscode.Uri.joinPath( + workspaceRoot, + LEARNING_WORKSPACE_FOLDER, + ); + + await this.loadWorkspace(workspaceRoot, katasRoot); + this._writingProgress = true; + try { + await this.saveProgress(); + } finally { + this._writingProgress = false; + } + this.startWatcher(); + sendTelemetryEvent( + EventType.LearningSessionStarted, + { isFirstTime: "true" }, + {}, + ); + return true; + } + + private async loadWorkspace( + workspaceRoot: vscode.Uri, + katasRoot: vscode.Uri, + ): Promise { + const learningFile = vscode.Uri.joinPath(workspaceRoot, LEARNING_FILE); + + const course = await loadKatasCourse(); + + // Build workspace state; assigned to this.workspace only after all + // async setup succeeds so that `initialized` stays false on failure. + const ws: WorkspaceState = { + workspaceRoot, + learningContentRoot: katasRoot, + learningFile, + catalog: course, + progressData: { + version: 1, + position: { + courseId: course.id, + unitId: course.units[0]?.id ?? "", + activityId: course.units[0]?.activities[0]?.id ?? "", + }, + completions: {}, + startedAt: new Date().toISOString(), + }, + }; + + await this.scaffoldExercises(ws); + await this.scaffoldExamples(ws); + await this.loadProgress(ws); + + // All async setup succeeded — publish the workspace. + this.workspace = ws; + this.syncContextKey(); + } + + private requireWorkspace(): WorkspaceState { + if (!this.workspace) { + throw new Error( + "No active learning workspace. Call tryInitialize() before using this method.", + ); + } + return this.workspace; + } + + private syncContextKey(): void { + void vscode.commands.executeCommand( + "setContext", + LEARNING_WORKSPACE_DETECTED_CONTEXT, + this.workspace !== undefined, + ); + } + + /** The default action: "check" or "run" if incomplete, "next" once done. */ + private getPrimaryAction(): PrimaryAction { + const { activity } = this.findCurrentActivity(); + if (activity.type === "exercise") { + return this.isComplete(this.position) ? "next" : "check"; + } + if (activity.type === "lesson" && activity.example) { + return this.isComplete(this.position) ? "next" : "run"; + } + return "next"; + } + + /** Builds the button groups shown in the webview toolbar for the current activity. */ + private getAvailableActions(): ActionGroup[] { + const { activity } = this.findCurrentActivity(); + const primary = this.getPrimaryAction(); + + const primaryLabel: Record = { + next: "Next", + run: "Run", + check: "Check", + }; + + const primaryGroup: ActionGroup = [ + { + key: "space", + label: primaryLabel[primary], + action: primary, + primary: true, + }, + ]; + + const navGroup: ActionGroup = [{ key: "b", label: "Back", action: "back" }]; + + if (activity.type === "exercise") { + // When completed, keep Check available so users can re-validate. + // When incomplete, offer a Hint button instead. + const isComplete = this.isComplete(this.position); + const extraGroups: ActionGroup[] = isComplete + ? [[{ key: "c", label: "Check", action: "check" }]] + : [ + [ + { + key: "h", + label: "Hint", + action: "hint-chat", + codicon: "sparkle", + }, + ], + ]; + return [primaryGroup, ...extraGroups, navGroup].filter( + (g) => g.length > 0, + ); + } + + // Lesson (text or example) + const codeTools: ActionGroup = + activity.example && primary !== "run" + ? [{ key: "r", label: "Run", action: "run" }] + : []; + const aiGroup: ActionGroup = [ + { + key: "e", + label: "Explain", + action: "explain-chat", + codicon: "sparkle", + }, + ]; + return [primaryGroup, codeTools, aiGroup, navGroup].filter( + (g) => g.length > 0, + ); + } + + /** Turns a catalog activity into the typed content payload (exercise, lesson-example, or lesson-text). */ + private resolveActivityContent( + location: ActivityLocation, + kata: CatalogUnit, + activity: CatalogActivity, + ): ActivityContent { + const ws = this.requireWorkspace(); + + if (activity.type === "exercise") { + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "exercises", + kata.id, + `${activity.id}.qs`, + ); + return { + type: "exercise", + id: activity.id, + title: activity.title, + description: activity.description, + filePath: fileUri.toString(), + isComplete: this.isComplete(location), + } satisfies ExerciseContent; + } + + // Lesson with a code example + if (activity.example) { + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "examples", + kata.id, + `${activity.example.id}.qs`, + ); + return { + type: "lesson-example", + id: activity.example.id, + code: activity.example.code, + filePath: fileUri.toString(), + contentBefore: activity.contentBefore, + contentAfter: activity.contentAfter, + } satisfies LessonExampleContent; + } + + // Text-only lesson + return { + type: "lesson-text", + content: activity.content ?? "", + } satisfies LessonTextContent; + } + + private findCurrentActivity(): { + unit: CatalogUnit; + activity: CatalogActivity; + } { + const pos = this.position; + const unit = this.findUnit(pos.unitId); + const activity = unit.activities.find((s) => s.id === pos.activityId); + if (!activity) { + throw new Error(`Activity not found: ${pos.activityId}`); + } + return { unit, activity }; + } + + private resolveExercise(): CatalogExercise { + const { activity } = this.findCurrentActivity(); + if (activity.type !== "exercise") { + throw new Error("Current activity is not an exercise"); + } + return activity; + } + + /** Returns the next activity in catalog order, or `undefined` at the end. */ + private nextActivity( + location: ActivityLocation, + ): ActivityLocation | undefined { + const ws = this.requireWorkspace(); + let found = false; + for (const unit of ws.catalog.units) { + for (const a of unit.activities) { + if (found) { + return { + courseId: ws.catalog.id, + unitId: unit.id, + activityId: a.id, + }; + } + if (unit.id === location.unitId && a.id === location.activityId) { + found = true; + } + } + } + return undefined; + } + + /** Returns the previous activity in catalog order, or `undefined` at the start. */ + private previousActivity( + location: ActivityLocation, + ): ActivityLocation | undefined { + const ws = this.requireWorkspace(); + let prev: ActivityLocation | undefined; + for (const unit of ws.catalog.units) { + for (const a of unit.activities) { + if (unit.id === location.unitId && a.id === location.activityId) { + return prev; + } + prev = { + courseId: ws.catalog.id, + unitId: unit.id, + activityId: a.id, + }; + } + } + return undefined; + } + + private findUnit(unitId: string): CatalogUnit { + const kata = this.requireWorkspace().catalog.units.find( + (k) => k.id === unitId, + ); + if (!kata) { + throw new Error(`Unit not found: ${unitId}`); + } + return kata; + } + + private async markExerciseComplete( + location: ActivityLocation, + ): Promise { + this.markComplete(location); + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + + const unit = this.requireWorkspace().catalog.units.find( + (u) => u.id === location.unitId, + ); + const exercises = + unit?.activities.filter((s) => s.type === "exercise") ?? []; + const exerciseIndex = exercises.findIndex( + (e) => e.id === location.activityId, + ); + sendTelemetryEvent( + EventType.LearningExerciseCompleted, + {}, + { + exerciseNumber: exerciseIndex + 1, + totalExercises: exercises.length, + }, + ); + } + + private async loadProgress(ws: WorkspaceState): Promise { + try { + const bytes = await vscode.workspace.fs.readFile(ws.learningFile); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + if ( + parsed && + typeof parsed === "object" && + parsed.version === 1 && + typeof parsed.completions === "object" && + parsed.completions !== null && + typeof parsed.position === "object" && + parsed.position !== null + ) { + ws.progressData = parsed as ProgressFileData; + // Validate saved position references a known unit and activity + if (ws.catalog.units.length > 0) { + const unit = ws.catalog.units.find( + (k) => k.id === ws.progressData.position.unitId, + ); + const activityValid = + unit && + unit.activities.some( + (s) => s.id === ws.progressData.position.activityId, + ); + if (!activityValid) { + ws.progressData.position = { + courseId: ws.catalog.id, + unitId: ws.catalog.units[0].id, + activityId: ws.catalog.units[0].activities[0]?.id ?? "", + }; + } + } + return; + } + } catch { + // expected when file is missing or corrupt + } + ws.progressData = this.freshProgressData(); + } + + private async saveProgress(): Promise { + const ws = this.requireWorkspace(); + const json = JSON.stringify(ws.progressData, null, 2); + this._writingProgress = true; + try { + await vscode.workspace.fs.writeFile( + ws.learningFile, + new TextEncoder().encode(json), + ); + } finally { + this._writingProgress = false; + } + this.emitProgress(); + } + + private freshProgressData(): ProgressFileData { + const ws = this.requireWorkspace(); + return { + version: 1, + position: { + courseId: ws.catalog.id, + unitId: ws.catalog.units[0]?.id ?? "", + activityId: ws.catalog.units[0]?.activities[0]?.id ?? "", + }, + completions: {}, + startedAt: new Date().toISOString(), + }; + } + + async reloadProgress(): Promise { + const ws = this.requireWorkspace(); + await this.loadProgress(ws); + this.emitProgress(); + this._onDidChangeState.fire(this.getState()); + } + + private completionKey(location: ActivityLocation): string { + return `${location.courseId}__${location.unitId}__${location.activityId}`; + } + + private findCompletion( + location: ActivityLocation, + ): { completedAt: string } | undefined { + return this.requireWorkspace().progressData.completions[ + this.completionKey(location) + ]; + } + + private isComplete(location: ActivityLocation): boolean { + return this.findCompletion(location) != null; + } + + private markComplete(location: ActivityLocation): void { + const key = this.completionKey(location); + const completions = this.requireWorkspace().progressData.completions; + if (!(key in completions)) { + completions[key] = { + completedAt: new Date().toISOString(), + }; + } + } + + private markIncomplete(location: ActivityLocation): void { + const key = this.completionKey(location); + delete this.requireWorkspace().progressData.completions[key]; + } + + private startWatcher(): void { + if (this._progressFileWatcher) { + return; + } + + const ws = this.requireWorkspace(); + const pattern = new vscode.RelativePattern(ws.workspaceRoot, LEARNING_FILE); + this._progressFileWatcher = + vscode.workspace.createFileSystemWatcher(pattern); + + const onDelete = () => { + if (this._writingProgress) { + return; + } + // File removed externally — tear down all workspace state. + this.workspace = undefined; + this.syncContextKey(); + this._lastSnapshot = undefined; + this._onDidChangeProgress.fire(undefined); + }; + + this._progressFileWatcher.onDidCreate(() => { + if (!this.workspace) { + void this.tryInitialize(); + } + }); + this._progressFileWatcher.onDidDelete(onDelete); + + this.emitProgress(); + } + + private emitProgress(): void { + if (!this.workspace) { + this._lastSnapshot = undefined; + this._onDidChangeProgress.fire(undefined); + return; + } + this._lastSnapshot = this.getProgress(); + this._onDidChangeProgress.fire(this._lastSnapshot); + } + + private async scaffoldExercises(ws: WorkspaceState): Promise { + for (const kata of ws.catalog.units) { + for (const activity of kata.activities) { + if (activity.type !== "exercise") { + continue; + } + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "exercises", + kata.id, + `${activity.id}.qs`, + ); + if (await this.uriExists(fileUri)) { + continue; + } + await this.ensureParentDir(fileUri); + await vscode.workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(activity.placeholderCode), + ); + } + } + } + + private async scaffoldExamples(ws: WorkspaceState): Promise { + for (const kata of ws.catalog.units) { + for (const activity of kata.activities) { + if (activity.type !== "lesson" || !activity.example) { + continue; + } + const fileUri = vscode.Uri.joinPath( + ws.learningContentRoot, + "examples", + kata.id, + `${activity.example.id}.qs`, + ); + await this.ensureParentDir(fileUri); + await vscode.workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(activity.example.code), + ); + } + } + } + + private async uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } + } + + private async ensureParentDir(fileUri: vscode.Uri): Promise { + const parentUri = vscode.Uri.joinPath(fileUri, ".."); + try { + await vscode.workspace.fs.createDirectory(parentUri); + } catch { + // already exists + } + } +} diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts new file mode 100644 index 0000000000..6f89be923b --- /dev/null +++ b/source/vscode/src/learning/types.d.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Shared types for the QDK Learning feature. + * + * Taxonomy: Course → Unit → Activity + * + * - **Course**: a top-level learning experience (e.g. "Quantum Katas"). + * - **Unit**: a thematic group of activities (maps to a kata in the content). + * - **Activity**: a single lesson or exercise within a unit. + */ + +// ─── Telemetry ─── + +export type TelemetrySource = "panel" | "chat" | "tree"; + +// ─── Location ─── + +export interface ActivityLocation { + courseId: string; + unitId: string; + activityId: string; +} + +// ─── Navigation ─── + +export interface CurrentActivity { + location: ActivityLocation; + unitTitle: string; + activityTitle: string; + content: ActivityContent; +} + +export type ActivityContent = + | LessonTextContent + | LessonExampleContent + | ExerciseContent; + +export interface LessonTextContent { + type: "lesson-text"; + content: string; +} + +export interface LessonExampleContent { + type: "lesson-example"; + id: string; + code: string; + /** URI string for the standalone .qs file */ + filePath: string; + contentBefore?: string; + contentAfter?: string; +} + +export interface ExerciseContent { + type: "exercise"; + id: string; + title: string; + description: string; + /** URI string for the user's .qs solution file */ + filePath: string; + isComplete: boolean; +} + +// ─── Actions ─── + +export type PrimaryAction = "next" | "run" | "check"; + +export type Action = + | "next" + | "back" + | "run" + | "check" + | "hint-chat" + | "explain-chat" + | "progress" + | "menu" + | "quit"; + +export interface ActionBinding { + /** Keyboard shortcut key (single character like "b", or "space"). */ + key: string; + label: string; + action: Action; + primary?: boolean; + /** Codicon name to display as an icon prefix on the button. */ + codicon?: string; +} + +export type ActionGroup = ActionBinding[]; + +// ─── Progress ─── + +export type ActivityKind = "lesson" | "exercise"; + +export interface ActivityProgress { + id: string; + title: string; + type: ActivityKind; + isComplete: boolean; + completedAt?: string; + /** True for lessons that contain at least one code example. */ + hasExample?: boolean; + /** For lessons with examples, the id of the first example part. */ + exampleId?: string; +} + +export interface UnitProgress { + id: string; + title: string; + total: number; + completed: number; + activities: ActivityProgress[]; +} + +export interface OverallProgress { + units: UnitProgress[]; + currentPosition: ActivityLocation; + stats: { totalActivities: number; completedActivities: number }; +} + +// ─── Progress file (qdk-learning.json) ─── + +export interface ProgressFileData { + version: 1; + position: ActivityLocation; + completions: Record; + startedAt: string; +} + +// ─── Bundled state ─── + +export interface HintContext { + hints: string[]; + solutionExplanation: string; +} + +export interface LearningState { + position: CurrentActivity; + actions: ActionGroup[]; + progress: OverallProgress; +} + +export interface NavigationResult { + moved: boolean; +} + +// ─── Code execution results ─── + +export interface SolutionCheckResult { + passed: boolean; + messages: string[]; + error?: string; +} + +export interface RunResult { + success: boolean; + messages: string[]; + result?: string; + error?: string; +} + +// ─── Webview messages ─── + +/** Messages sent from the extension host to the webview panel. */ +export type HostToWebviewMessage = + /** Full state push. */ + | { command: "state"; state: LearningState } + /** Forward navigation completed. `result.moved` is false when the user is already at the last activity. */ + | { + command: "result"; + action: "next"; + result: NavigationResult; + state: LearningState; + } + /** Backward navigation completed. `result.moved` is false when the user is already at the first activity. */ + | { + command: "result"; + action: "back"; + result: NavigationResult; + state: LearningState; + } + /** Exercise check completed. Contains pass/fail, captured output events, and any compiler/runtime error. */ + | { + command: "result"; + action: "check"; + result: SolutionCheckResult; + state: LearningState; + } + /** Run action completed. Result is empty — output is shown in the VS Code editor/terminal, not the webview. */ + | { + command: "result"; + action: "run"; + result: Record; + state: LearningState; + } + /** An action failed with an unrecoverable error. */ + | { command: "error"; message: string }; + +type ResultMessage = Extract; +export type ResultAction = ResultMessage["action"]; +export type ResultPayload = Extract< + ResultMessage, + { action: Action } +>["result"]; + +/** Messages sent from the webview panel to the extension host. */ +export type WebviewToHostMessage = + /** Initialization handshake. Tells the host the webview is ready; triggers initial state push and file open. */ + | { command: "ready" } + /** User triggered an action (next, back, run, check, etc.). */ + | { command: "action"; action: Action } + /** Open a file in the editor (e.g. exercise or example .qs file). */ + | { command: "openFile"; uri: string } + /** Open Copilot Chat with a learning-context query. */ + | { command: "openChat"; text: string } + /** Focus the learning progress tree view in the sidebar. */ + | { command: "focusProgress" }; + +// ─── Catalog ─── +// +// Flattened representation of the katas content. Derived from the raw +// qsharp-lang Kata types in `catalog.ts`. + +export interface CatalogExercise { + type: "exercise"; + id: string; + title: string; + /** Exercise description (markdown). */ + description: string; + /** Starter code written to the user's exercise file. */ + placeholderCode: string; + /** IDs of global source files needed for exercise checking. */ + sourceIds: string[]; + /** Author-written pedagogical hints. */ + hints: string[]; + /** Reference solution code. */ + solutionCode: string; + /** Prose explanation of the solution (markdown). */ + solutionExplanation: string; +} + +export interface CatalogLesson { + type: "lesson"; + id: string; + title: string; + /** If the lesson contains a code example, its id and code. */ + example?: { id: string; code: string }; + /** Markdown text before the example (only when `example` is set). */ + contentBefore?: string; + /** Markdown text after the example (only when `example` is set). */ + contentAfter?: string; + /** Merged markdown text (only when there is no example). */ + content?: string; +} + +export type CatalogActivity = CatalogExercise | CatalogLesson; + +export interface CatalogUnit { + id: string; + title: string; + activities: CatalogActivity[]; +} + +export interface CatalogCourse { + id: string; + title: string; + units: CatalogUnit[]; +} + +export interface UnitSummary { + id: string; + title: string; + activityCount: number; + completedCount: number; + /** True if this is the first unit that hasn't been fully completed. */ + firstIncomplete: boolean; +} diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 81cbfa4275..3258793c8c 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -20,7 +20,6 @@ import type { ActivityContent, ActivityLocation, SolutionCheckResult, - OutputEvent, LearningState, HostToWebviewMessage, } from "../types.js"; @@ -431,7 +430,7 @@ function SolutionResult({ result }: { result: SolutionCheckResult }) { ) : (
✘ Check failed
)} - + {result.error &&
{result.error}
} {!result.passed && ( - {events.map((event, i) => { - switch (event.type) { - case "message": - return ( -
- {event.message} -
- ); - case "dump": - return ; - case "matrix": - return ; - default: - return null; - } - })} + {messages.map((msg, i) => ( +
+ {msg} +
+ ))} ); } -function formatComplex(real: number, imag: number): string { - const r = `${real <= -0.00005 ? "\u2212" : ""}${Math.abs(real).toFixed(4)}`; - const i = `${imag <= -0.00005 ? "\u2212" : "+"}${Math.abs(imag).toFixed(4)}\u{1D456}`; - return `${r}${i}`; -} - -function StateTable({ state }: { state: Record }) { - const entries = Object.entries(state); - if (entries.length === 0) { - return
No qubits allocated
; - } - return ( - - - - - - - - - - - {entries.map(([basis, [real, imag]]) => { - const prob = (real * real + imag * imag) * 100; - const phase = Math.atan2(imag, real); - return ( - - - - - - - - ); - })} - -
Basis StateAmplitudeMeasurement ProbabilityPhase
{basis}{formatComplex(real, imag)} - - {prob.toFixed(4)}% - {phase.toFixed(4)}
- ); -} - -function MatrixTable({ matrix }: { matrix: number[][][] }) { - return ( - - - {matrix.map((row, r) => ( - - {row.map(([real, imag], c) => ( - - ))} - - ))} - -
- {formatComplex(real, imag)} -
- ); -} - function ActionBar({ groups, busy: isBusy, diff --git a/source/vscode/src/learning/welcomeView.ts b/source/vscode/src/learning/welcomeView.ts new file mode 100644 index 0000000000..a449401b17 --- /dev/null +++ b/source/vscode/src/learning/welcomeView.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import type { LearningService } from "./service.js"; + +const viewId = "qsharp-vscode.learningWelcome"; + +/** + * Registers a WebviewView that shows the QDK Learning welcome screen + * (with the colorful Möbius logo) when no learning workspace is detected. + */ +export function registerLearningWelcomeView( + context: vscode.ExtensionContext, + service: LearningService, +): void { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(viewId, { + resolveWebviewView(webviewView: vscode.WebviewView) { + // The welcome view is shown while no learning workspace has been + // detected. Probe for an existing one here so startup can transition + // to the progress tree without requiring another entry point. + service.tryInitialize(); + + webviewView.webview.options = { + enableScripts: false, + enableCommandUris: true, + localResourceRoots: [ + vscode.Uri.joinPath(context.extensionUri, "resources"), + vscode.Uri.joinPath(context.extensionUri, "out"), + ], + }; + + const logoUri = webviewView.webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "resources", "mobius.png"), + ); + + const codiconCssUri = webviewView.webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "out", + "katex", + "codicon.css", + ), + ); + + webviewView.webview.html = getHtml( + logoUri, + codiconCssUri, + webviewView.webview.cspSource, + ); + }, + }), + ); +} + +function getHtml( + logoUri: vscode.Uri, + codiconCssUri: vscode.Uri, + cspSource: string, +): string { + return ` + + + + + + + + + + +

Learn Quantum Computing

+

+ No experience needed. Work through the Quantum Katas — + guided lessons and hands-on exercises at your own pace. +

+ Start learning +

+ Progress is saved in this workspace. Worked on these before? + Open that folder. +

+ +`; +} diff --git a/source/vscode/src/run.ts b/source/vscode/src/run.ts index 87396bf6ca..bdf51d2c79 100644 --- a/source/vscode/src/run.ts +++ b/source/vscode/src/run.ts @@ -88,7 +88,7 @@ export function runProgramInTerminal( } } -const enum ProgramRunStatus { +export enum ProgramRunStatus { AllShotsDone = "all shots done", Timeout = "timeout", Cancellation = "cancellation", diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index 53056568e0..46f43a362f 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -61,6 +61,9 @@ export enum EventType { RemoveOldCopilotInstructions = "Qsharp.RemoveOldCopilotInstructions", ChangelogPromptStart = "Qsharp.ChangelogPromptStart", ChangelogPromptEnd = "Qsharp.ChangelogPromptEnd", + LearningSessionStarted = "Qsharp.LearningSessionStarted", + LearningActivityAction = "Qsharp.LearningActivityAction", + LearningExerciseCompleted = "Qsharp.LearningExerciseCompleted", } type Empty = { [K in any]: never }; @@ -330,6 +333,27 @@ type EventTypes = { }; measurements: Empty; }; + [EventType.LearningSessionStarted]: { + properties: { + isFirstTime: string; + }; + measurements: Empty; + }; + [EventType.LearningActivityAction]: { + properties: { + action: "navigate" | "run" | "check" | "hint" | "solution" | "reset"; + activityType: "lesson" | "exercise"; + source: "panel" | "chat" | "tree"; + }; + measurements: Empty; + }; + [EventType.LearningExerciseCompleted]: { + properties: Empty; + measurements: { + exerciseNumber: number; + totalExercises: number; + }; + }; }; export enum QsharpDocumentType { From 5156851874f9ef06e761dc1dd892cb2717913482 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 07:36:23 +0000 Subject: [PATCH 17/26] package.json --- source/vscode/package.json | 399 ++++++++++++++++++++++++++++++++++++- 1 file changed, 397 insertions(+), 2 deletions(-) diff --git a/source/vscode/package.json b/source/vscode/package.json index 58c31448a2..a8ca447287 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -301,6 +301,34 @@ { "command": "qsharp-vscode.addProjectReference", "when": "resourceFilename == qsharp.json" + }, + { + "command": "qsharp-vscode.learningOpenActivity", + "when": "false" + }, + { + "command": "qsharp-vscode.learningCheckSolution", + "when": "false" + }, + { + "command": "qsharp-vscode.learningResetExercise", + "when": "false" + }, + { + "command": "qsharp-vscode.learningShowActivity", + "when": "false" + }, + { + "command": "qsharp-vscode.learningRefresh", + "when": "false" + }, + { + "command": "qsharp-vscode.learningContinue", + "when": "false" + }, + { + "command": "qsharp-vscode.learningAskInChat", + "when": "false" } ], "view/title": [ @@ -313,6 +341,21 @@ "command": "qsharp-vscode.workspacesAdd", "when": "view == quantum-workspaces", "group": "navigation" + }, + { + "command": "qsharp-vscode.learningRefresh", + "when": "view == qsharp-vscode.learningTree", + "group": "navigation" + }, + { + "command": "qsharp-vscode.learningContinue", + "when": "view == qsharp-vscode.learningTree", + "group": "navigation" + }, + { + "command": "qsharp-vscode.learningShowActivity", + "when": "view == qsharp-vscode.learningTree", + "group": "navigation" } ], "view/item/context": [ @@ -358,6 +401,16 @@ { "command": "qsharp-vscode.workspacePythonCode", "when": "view == quantum-workspaces && viewItem == workspace" + }, + { + "command": "qsharp-vscode.learningOpenActivity", + "group": "inline", + "when": "view == qsharp-vscode.learningTree && (viewItem == continue || viewItem == unit || viewItem == lesson || viewItem == exercise || viewItem == example)" + }, + { + "command": "qsharp-vscode.learningAskInChat", + "group": "inline", + "when": "view == qsharp-vscode.learningTree && (viewItem == continue || viewItem == unit || viewItem == lesson || viewItem == exercise || viewItem == example)" } ], "explorer/context": [ @@ -375,13 +428,34 @@ } ] }, - "viewsContainers": {}, + "viewsContainers": { + "activitybar": [ + { + "id": "qsharp-vscode-katas", + "title": "Microsoft Quantum", + "icon": "./resources/mobius.svg" + } + ] + }, "views": { "explorer": [ { "id": "quantum-workspaces", "name": "Quantum Workspaces" } + ], + "qsharp-vscode-katas": [ + { + "id": "qsharp-vscode.learningWelcome", + "name": "Welcome", + "type": "webview", + "when": "!qsharp-vscode.learningWorkspaceDetected" + }, + { + "id": "qsharp-vscode.learningTree", + "name": "Learning", + "when": "qsharp-vscode.learningWorkspaceDetected" + } ] }, "viewsWelcome": [ @@ -403,6 +477,16 @@ "path": "./skills/qdk-programming/SKILL.md" } ], + "chatPromptFiles": [ + { + "path": "./prompts/qdk-learning.prompt.md" + } + ], + "chatAgents": [ + { + "path": "./agents/qdk-learning.agent.md" + } + ], "commands": [ { "command": "qsharp-vscode.gotoLocations", @@ -557,6 +641,48 @@ "command": "qsharp-vscode.showChangelog", "title": "Show Changelog", "category": "QDK" + }, + { + "command": "qsharp-vscode.learningRefresh", + "title": "Refresh Learning Progress", + "category": "QDK Learning", + "icon": "$(refresh)" + }, + { + "command": "qsharp-vscode.learningContinue", + "title": "Continue Learning", + "category": "QDK Learning", + "icon": "$(chat-sparkle)" + }, + { + "command": "qsharp-vscode.learningOpenActivity", + "title": "Go to Activity", + "category": "QDK Learning", + "icon": "$(forward)" + }, + { + "command": "qsharp-vscode.learningAskInChat", + "title": "Ask in Chat", + "category": "QDK Learning", + "icon": "$(comment-discussion-sparkle)" + }, + { + "command": "qsharp-vscode.learningCheckSolution", + "title": "Check Solution", + "category": "QDK Learning", + "icon": "$(pass)" + }, + { + "command": "qsharp-vscode.learningResetExercise", + "title": "Reset Exercise", + "category": "QDK Learning", + "icon": "$(discard)" + }, + { + "command": "qsharp-vscode.learningShowActivity", + "title": "Show Current Activity", + "category": "QDK Learning", + "icon": "$(mortar-board)" } ], "breakpoints": [ @@ -1075,6 +1201,274 @@ "required": [], "additionalProperties": false } + }, + { + "name": "qdk-learning-show", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningShow", + "displayName": "QDK Learning: Show Activity", + "modelDescription": "Show the current learning activity. Opens the interactive Katas panel for lessons and exercises. Use to start or resume a learning session.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-get-state", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningGetState", + "displayName": "QDK Learning: Get State", + "modelDescription": "Read the current learning position and progress without opening the panel or requiring initialization. Returns { initialized: false } if the workspace is not yet set up. Use at startup to check status before calling show.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-get-progress", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningGetProgress", + "displayName": "QDK Learning: Get Progress", + "modelDescription": "Return the full per-unit progress breakdown with activity-level completion status.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-list-units", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningListUnits", + "displayName": "QDK Learning: List Units", + "modelDescription": "List all available units with completion status.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-next", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningNext", + "displayName": "QDK Learning: Next", + "modelDescription": "Move to the next part in the learning sequence. The panel updates automatically.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-previous", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningPrevious", + "displayName": "QDK Learning: Previous", + "modelDescription": "Move to the previous part in the learning sequence. The panel updates automatically.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-goto", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningGoto", + "displayName": "QDK Learning: Go To", + "modelDescription": "Jump to a specific course, unit, and optionally an activity. Use courseId + unitId from list-units or get-state. courseId defaults to 'katas' (Quantum Katas).", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "courseId": { + "type": "string", + "description": "ID of the course. Defaults to 'katas' (built-in Quantum Katas)." + }, + "unitId": { + "type": "string", + "description": "ID of the unit (e.g. 'getting_started')" + }, + "activityId": { + "type": "string", + "description": "Activity ID within the unit. Omit to jump to the first activity." + } + }, + "required": [ + "unitId" + ], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-run", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningRun", + "displayName": "QDK Learning: Run", + "modelDescription": "Run the Q# code at the current position (examples only). Returns execution events and result.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": { + "shots": { + "type": "number", + "description": "Number of times to run the program. Defaults to 1.", + "default": 1 + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-read-code", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningReadCode", + "displayName": "QDK Learning: Read Code", + "modelDescription": "Read the user's current Q# code for the active exercise or example. Returns code text and file path without opening the panel.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-check", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningCheck", + "displayName": "QDK Learning: Check", + "modelDescription": "Check the student's solution against the test harness. Marks the exercise complete on pass. Only valid on exercises.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-hint", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningHint", + "displayName": "QDK Learning: Hint", + "modelDescription": "Return the built-in hints and solution explanation for the current exercise. Only valid on exercises.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-solution", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "toolReferenceName": "qdkLearningSolution", + "displayName": "QDK Learning: Solution", + "modelDescription": "Return the full reference solution code for the current exercise. Only valid on exercises.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "qdk-learning-reset", + "tags": [ + "qdk", + "qdk-learning", + "quantum-katas" + ], + "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.", + "canBeReferencedInPrompt": true, + "icon": "./resources/file-icon-light.svg", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } } ] }, @@ -1086,7 +1480,8 @@ "pretest": "npm exec --no playwright install chromium && node ./test/buildTests.mjs", "tsc:check:main": "node ../../node_modules/typescript/bin/tsc -p ./tsconfig.json", "tsc:check:view": "node ../../node_modules/typescript/bin/tsc -p ./src/webview/tsconfig.json", - "tsc:check": "npm run tsc:check:main && npm run tsc:check:view", + "tsc:check:learning": "node ../../node_modules/typescript/bin/tsc -p ./src/learning/webview/tsconfig.json", + "tsc:check": "npm run tsc:check:main && npm run tsc:check:view && npm run tsc:check:learning", "tsc:watch": "node ../../node_modules/typescript/bin/tsc -p ./tsconfig.json --watch --preserveWatchOutput", "tsc:watch:view": "node ../../node_modules/typescript/bin/tsc -p ./src/webview/tsconfig.json --watch --preserveWatchOutput" }, From f1a973fb4af170de535917d1a5043dfea2d4e3ac Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 20:54:02 +0000 Subject: [PATCH 18/26] Fix workspace init bug: freshProgressData called before this.workspace was set --- source/vscode/src/learning/service.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index ca7b605116..8bf34d3960 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -987,7 +987,16 @@ export class LearningService { } catch { // expected when file is missing or corrupt } - ws.progressData = this.freshProgressData(); + ws.progressData = { + version: 1, + position: { + courseId: ws.catalog.id, + unitId: ws.catalog.units[0]?.id ?? "", + activityId: ws.catalog.units[0]?.activities[0]?.id ?? "", + }, + completions: {}, + startedAt: new Date().toISOString(), + }; } private async saveProgress(): Promise { @@ -1005,19 +1014,7 @@ export class LearningService { this.emitProgress(); } - private freshProgressData(): ProgressFileData { - const ws = this.requireWorkspace(); - return { - version: 1, - position: { - courseId: ws.catalog.id, - unitId: ws.catalog.units[0]?.id ?? "", - activityId: ws.catalog.units[0]?.activities[0]?.id ?? "", - }, - completions: {}, - startedAt: new Date().toISOString(), - }; - } + async reloadProgress(): Promise { const ws = this.requireWorkspace(); From 9b2b84ea5a23ba2a7fad5ab51ec2f1ebcaec4897 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Mon, 18 May 2026 16:43:07 -0700 Subject: [PATCH 19/26] fix formatting --- source/vscode/src/learning/service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 8bf34d3960..6feff1daa4 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -1014,8 +1014,6 @@ export class LearningService { this.emitProgress(); } - - async reloadProgress(): Promise { const ws = this.requireWorkspace(); await this.loadProgress(ws); From bbce5d95bba386d9a729369af0fe75e5a40fa50e Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 19 May 2026 23:17:26 +0000 Subject: [PATCH 20/26] Clean up learning webview CSS: remove dead code, add comments, fix class names --- .../src/learning/webview/webview-client.tsx | 16 +- .../vscode/src/learning/webview/webview.css | 342 ++++++++++++------ 2 files changed, 231 insertions(+), 127 deletions(-) diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 3258793c8c..232bbb3ac6 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -397,9 +397,7 @@ function OutputPanel({ ×
{label}
-
- -
+ ); } @@ -413,7 +411,7 @@ function OutputBody({ output: out }: { output: OutputState }) { out.variant === "pass" ? "success" : out.variant === "fail" - ? "fail" + ? "out-fail" : "message"; return
{out.text}
; } @@ -428,10 +426,10 @@ function SolutionResult({ result }: { result: SolutionCheckResult }) { {result.passed ? (
✔ All tests passed!
) : ( -
✘ Check failed
+
✘ Check failed
)} - {result.error &&
{result.error}
} + {result.error &&
{result.error}
} {!result.passed && ( - + {stats.completedActivities}/{stats.totalActivities} ({pct}%) {currentUnit ? ( <> - {currentUnit.title} + {currentUnit.title} {currentUnit.activities.map((act) => { const isCurrent = act.id === currentPosition!.activityId; @@ -595,7 +593,7 @@ function ProgressBar({ progress }: { progress: OverallProgress }) { ) : currentPosition && currentPosition.unitId ? ( - {currentPosition.unitId} + {currentPosition.unitId} ) : null} ); diff --git a/source/vscode/src/learning/webview/webview.css b/source/vscode/src/learning/webview/webview.css index 88d285068e..80a2eb2770 100644 --- a/source/vscode/src/learning/webview/webview.css +++ b/source/vscode/src/learning/webview/webview.css @@ -1,34 +1,65 @@ /* Copyright (c) Microsoft Corporation. Licensed under the MIT License. */ +/* Ensures padding/border are included in width/height */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Ensures the page fills the panel height */ +html { + height: 100%; +} + :root { color-scheme: light dark; + + /* Shared corner rounding for badges, code blocks, buttons... */ + --radius: 4px; + /* Page background. Matches the VS Code editor so the panel blends in */ --bg: var(--vscode-editor-background, transparent); + /* Body text color. */ --fg: var(--vscode-editor-foreground, #1a1a1a); + /* De-emphasized text for labels, breadcrumbs... */ --muted: var(--vscode-descriptionForeground, #6a6a6a); - --accent: var(--vscode-textLink-foreground, #0066cc); + /* Interactive/link color — hyperlinks, active indicators, default output accent */ + --accent: var(--vscode-textLink-foreground, #06c); + /* Hovered link color */ --accent-hover: var(--vscode-textLink-activeForeground, var(--accent)); + /* Separators and outlines throughout the panel */ --border: var(--vscode-widget-border, var(--vscode-panel-border, #e0e0e0)); + /* Green for passed checks */ --success: var( --vscode-charts-green, var(--vscode-testing-iconPassed, #1a7f37) ); + /* Red for failed checks and error messages */ --fail: var(--vscode-errorForeground, var(--vscode-charts-red, #cf222e)); - --hint: var( - --vscode-charts-yellow, - var(--vscode-editorWarning-foreground, #825d00) - ); - --code-bg: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.08)); + /* Subtle tinted background for code blocks, badges, and the output card */ + --code-bg: var(--vscode-textCodeBlock-background, rgb(127 127 127 / 8%)); + /* Primary action button fill (e.g., "Check", "Next") */ --btn-bg: var(--vscode-button-background, var(--accent)); + /* Primary action button text */ --btn-fg: var(--vscode-button-foreground, #fff); - --btn-hover-bg: var(--vscode-button-hoverBackground, var(--accent)); + /* Primary action button on hover */ + --btn-hover-bg: var( + --vscode-button-hoverBackground, + color-mix(in srgb, var(--accent) 85%, black) + ); + /* Secondary button fill (e.g., "Back", "Hint") */ --btn-secondary-bg: var(--vscode-button-secondaryBackground, transparent); + /* Secondary button text */ --btn-secondary-fg: var(--vscode-button-secondaryForeground, var(--fg)); + /* Hover tint for secondary buttons and the progress footer */ --btn-secondary-hover-bg: var( --vscode-button-secondaryHoverBackground, - transparent + rgb(127 127 127 / 10%) ); + /* Keyboard-focus ring shown on all interactive elements */ --focus-border: var(--vscode-focusBorder, var(--accent)); + /* UI (sans-serif) font used for all non-code text */ --ui-font: var( --vscode-font-family, system-ui, @@ -36,7 +67,7 @@ "Segoe UI", sans-serif ); - --ui-font-size: var(--vscode-font-size, 13px); + /* Monospace font for inline code and fenced code blocks */ --mono-font: var( --vscode-editor-font-family, ui-monospace, @@ -46,339 +77,414 @@ Consolas, monospace ); + + /* Font sizes — all text sizes in the panel, largest to smallest */ + --font-h1: 17px; + --font-h2: 16px; + --font-h3: 15px; + --font-icon: 14px; + --body-font-size: var(--vscode-font-size, 13px); + --font-sm: var(--vscode-bodyFontSize-small, 12px); + --font-xs: var(--vscode-bodyFontSize-xSmall, 11px); } +/* Sets up a flex column layout so the panel stacks: branding → header → content → output → action-bar → progress-bar. */ body { - font: var(--ui-font-size) / 1.45 var(--ui-font); + font: var(--body-font-size) / 1.45 var(--ui-font); margin: 0; background: var(--bg); color: var(--fg); display: flex; flex-direction: column; - height: 100vh; -} - -math { - font-size: 1.25em; -} -math[display="block"] { - font-size: 1.35em; + height: 100%; } -/* Branding header */ +/* Logo and text at the top of the panel. */ .branding { display: flex; align-items: center; - gap: 0.4rem; - padding: 0.45rem 0.75rem; + gap: 6px; + padding: 7px 12px; border-bottom: 1px solid var(--border); + /* Prevent this bar from shrinking when the flex column is tight */ flex-shrink: 0; - position: sticky; - top: 0; - z-index: 10; - background: var(--bg); } + +/* Logo image. */ .branding-icon { width: 18px; height: 18px; + /* Don't let the logo shrink if the text beside it is long */ flex-shrink: 0; } + +/* Logo text label. */ .branding-text { - font-size: 12px; + font-size: var(--font-sm); font-weight: 600; letter-spacing: 0.02em; - color: var(--fg); - opacity: 0.85; + color: var(--muted); } -/* Header */ +/* Breadcrumb bar showing "Unit › Activity" and a type badge. */ .header { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.75rem; + gap: 8px; + padding: 6px 12px; border-bottom: 1px solid var(--border); - font-size: 11px; + font-size: var(--font-xs); color: var(--muted); + /* Keep this bar fixed-height even when content pushes the flex column */ flex-shrink: 0; } + +/* Breadcrumb text — truncates with ellipsis when the panel is narrow. */ .header .crumb { flex: 1; + /* Required for ellipsis to work inside a flex item */ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Type badge ("lesson", "example", "exercise", "✔ done"). */ .header .badge { text-transform: uppercase; letter-spacing: 0.05em; - font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--code-bg); + /* Slightly stronger than the muted crumb text so the badge pops */ color: var(--fg); } + +/* Badge variant: incomplete exercise — accented to signal "needs action". */ .header .badge.exercise { background: var(--vscode-badge-background, var(--accent)); color: var(--vscode-badge-foreground, #fff); } + +/* Badge variant: completed exercise — green "done" state. */ .header .badge.complete { background: var(--success); - color: var(--btn-fg, #fff); + color: #fff; } -/* Content — full panel, scrollable (no max-height cap) */ +/* Main scrollable area holding rendered Markdown (lessons, examples, exercises). */ .content { - padding: 0.75rem 1rem; - overflow-y: auto; - flex: 1; - min-height: 0; + padding: 12px 16px; + overflow-y: auto; /* this is the panel's scroll container */ + flex: 1; /* fill all remaining vertical space */ + min-height: 0; /* let flex item shrink below content size so overflow scrolls */ } + +/* Markdown headings — tighter margins than browser defaults. */ .content h1, .content h2, .content h3 { - margin: 0.4rem 0 0.3rem; + margin: 6px 0 5px; } + .content h1 { - font-size: 1.05rem; + font-size: var(--font-h1); } + .content h2 { - font-size: 1rem; + font-size: var(--font-h2); } + .content h3 { - font-size: 0.95rem; + font-size: var(--font-h3); } + +/* Markdown paragraphs. */ .content p { - margin: 0.35rem 0; + margin: 6px 0; } + +/* Markdown lists. */ .content ul, .content ol { - margin: 0.35rem 0; - padding-left: 1.3rem; + margin: 6px 0; + padding-left: 21px; } + +/* Fenced code blocks. */ .content pre { background: var(--code-bg); - padding: 0.4rem 0.5rem; - border-radius: 4px; - overflow-x: auto; - font-size: 12px; - margin: 0.3rem 0; + padding: 6px 8px; + border-radius: var(--radius); + overflow-x: auto; /* horizontal scroll for wide code */ + font-size: var(--font-sm); + margin: 5px 0; } + +/* Inline and block code. */ .content code { font-family: var(--mono-font); } + +/* Markdown tables. */ .content table { border-collapse: collapse; - margin: 0.25rem 0; - font-size: 12px; + margin: 4px 0; + font-size: var(--font-sm); } + .content th, .content td { border: 1px solid var(--border); padding: 2px 6px; - text-align: left; + text-align: left; /* override default center-align on */ } + +/* Small muted note below content: "Your code file should be open…" */ .content .file-path { - font-size: 11px; + font-size: var(--font-xs); color: var(--muted); - margin-top: 0.5rem; + margin-top: 8px; } + +/* "open it here" link inside the file-path note. */ .content .file-path-link { color: var(--accent); text-decoration: none; } + .content .file-path-link:hover { text-decoration: underline; } -/* Completion banner (inline in exercise body) */ +.content .file-path-link:focus-visible { + outline: 1px solid var(--focus-border); + outline-offset: 1px; +} + +/* Green "✓ Correct!" banner shown when an exercise is complete. */ .content .completion-banner { display: inline-flex; align-items: center; - gap: 0.35rem; - margin-top: 0.6rem; - padding: 0.3rem 0.7rem; - border-radius: 4px; + gap: 6px; + margin-top: 10px; + padding: 5px 11px; + border-radius: var(--radius); background: color-mix(in srgb, var(--success) 12%, transparent); color: var(--success); - font-size: 12px; + font-size: var(--font-sm); font-weight: 600; } + +/* Used by: — the "✓" checkmark inside the completion banner. */ .completion-banner .completion-icon { - font-size: 14px; + font-size: var(--font-icon); } -/* Output slot */ +/* Result/output card between content and action bar. Color-coded left border + indicates status (accent = neutral, green = pass, red = fail). */ .output { - margin: 0 1rem 0.5rem; - padding: 0.4rem 0.6rem; - border-radius: 4px; + margin: 0 16px 8px; + padding: 6px 10px; + border-radius: var(--radius); background: var(--code-bg); - font-size: 12px; + font-size: var(--font-sm); border-left: 3px solid var(--accent); - position: relative; + position: relative; /* anchors the absolute dismiss button */ flex-shrink: 0; } + +/* Left-border override: tests passed. */ .output.pass { border-left-color: var(--success); } + +/* Left-border override: check failed or error. */ .output.fail { border-left-color: var(--fail); } + +/* "RESULT" / "OUTPUT" label at the top of the card. */ .output .out-label { - font-size: 10px; + font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); - margin-bottom: 0.25rem; + margin-bottom: 4px; } + +/* "×" dismiss button pinned to the top-right corner. */ .output .out-dismiss { position: absolute; top: 4px; right: 4px; - background: none; - border: none; + background: none; /* reset UA button style */ + border: none; /* reset UA button style */ color: var(--muted); cursor: pointer; - font-size: 14px; - line-height: 1; + font-size: var(--font-icon); + line-height: 1; /* prevent extra height from line-height on the "×" glyph */ padding: 2px 5px; } + .output .out-dismiss:hover { color: var(--fg); } -.output pre { - background: rgba(127, 127, 127, 0.12); - padding: 0.4rem 0.5rem; - border-radius: 3px; - font-size: 11px; - margin: 0.25rem 0; - overflow-x: auto; -} -.output table { - border-collapse: collapse; - margin: 0.25rem 0; - font-size: 11px; -} -.output th, -.output td { - border: 1px solid var(--border); - padding: 1px 5px; - text-align: left; + +.output .out-dismiss:focus-visible { + outline: 1px solid var(--focus-border); + outline-offset: 1px; } + +/* Green "✔ All tests passed!" text. */ .output .success { color: var(--success); white-space: pre-wrap; } -.output .fail { + +/* Red "✘ Check failed" or error message text. */ +.output .out-fail { color: var(--fail); white-space: pre-wrap; } + +/* Runtime messages (DumpMachine output, etc.) shown in mono. */ .output .message { - color: var(--muted); - font-style: italic; + font-family: var(--mono-font); white-space: pre-wrap; } -/* Action bar */ +/* Bottom toolbar with grouped action buttons (Back, Next, Check, Run, Hint…). */ .action-bar { display: flex; flex-wrap: wrap; - gap: 0.3rem; - padding: 0.4rem 0.75rem; + gap: 5px; + padding: 6px 12px; border-top: 1px solid var(--border); flex-shrink: 0; } + +/* Button group — separated from siblings by a vertical rule. */ .action-group { display: flex; - gap: 0.25rem; - padding-right: 0.4rem; - border-right: 1px solid var(--border); + gap: 4px; } -.action-group:last-child { - border-right: none; - padding-right: 0; + +.action-group:not(:last-child) { + padding-right: 6px; + border-right: 1px solid var(--border); } + +/* Base button style (secondary appearance by default). */ .action-bar button { - font: inherit; - font-size: 12px; + font: inherit; /* buttons don't inherit font by default */ + font-size: var(--font-sm); background: var(--btn-secondary-bg); color: var(--btn-secondary-fg); border: 1px solid var(--border); padding: 3px 9px; - border-radius: 4px; + border-radius: var(--radius); cursor: pointer; } + .action-bar button:hover { background: var(--btn-secondary-hover-bg); border-color: var(--accent); } + .action-bar button:focus-visible { outline: 1px solid var(--focus-border); outline-offset: 1px; } + +/* Primary action button ("Check", "Next") — filled accent background. */ .action-bar button.primary { background: var(--btn-bg); color: var(--btn-fg); border-color: var(--btn-bg); } + .action-bar button.primary:hover { background: var(--btn-hover-bg); border-color: var(--btn-hover-bg); } + +/* Disabled state while a check/run is in progress. */ .action-bar button:disabled { opacity: 0.5; - cursor: wait; + cursor: not-allowed; } -/* Progress bar footer */ +/* Clickable footer showing completion stats and activity dots. + Opens the progress tree view on click. */ .progress-bar { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 8px; align-items: center; - font-size: 11px; - padding: 0.3rem 0.75rem; + font-size: var(--font-xs); + padding: 5px 12px; color: var(--muted); border-top: 1px solid var(--border); flex-shrink: 0; cursor: pointer; } + .progress-bar:hover { background: var(--btn-secondary-hover-bg); } + +.progress-bar:focus-visible { + outline: 1px solid var(--focus-border); + outline-offset: -1px; /* inset because this sits at the viewport edge */ +} + +/* Row of small colored dots (one per activity in the current unit). */ .pb-segments { display: inline-flex; gap: 2px; } + +/* Individual activity dot — gray (not started) by default. */ .pb-seg { display: inline-block; - width: 7px; - height: 7px; + width: 8px; + height: 8px; border-radius: 2px; background: var(--border); } + +/* Completed activity dot (green). */ .pb-seg.done { background: var(--success); } + +/* Currently active activity dot (accent blue). */ .pb-seg.current { background: var(--accent); } -/* Chat entry-point links (inline in output area) */ +/* "What went wrong?" link — opens Copilot Chat with a help prompt. */ .chat-link { display: inline-block; - margin-top: 0.45rem; - font-size: 12px; + margin-top: 7px; + font-size: var(--font-sm); color: var(--accent); cursor: pointer; text-decoration: none; } + .chat-link:hover { text-decoration: underline; color: var(--accent-hover); } +.chat-link:focus-visible { + outline: 1px solid var(--focus-border); + outline-offset: 1px; +} + +/* "Loading…" / "Working…" placeholder text. */ .loading { color: var(--muted); font-style: italic; From 54660ffc038ea63501b63b6b65747974cf50a5c5 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Tue, 19 May 2026 23:17:33 +0000 Subject: [PATCH 21/26] Narrow LearningSessionStarted isFirstTime to literal union type --- source/vscode/src/telemetry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index 46f43a362f..8be53492aa 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -335,7 +335,7 @@ type EventTypes = { }; [EventType.LearningSessionStarted]: { properties: { - isFirstTime: string; + isFirstTime: "true" | "false"; }; measurements: Empty; }; From 0c88761544070f53596337f046c7e7215a531fc3 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 20 May 2026 06:25:55 +0000 Subject: [PATCH 22/26] Make navigation methods async and refactor learning tools API --- source/npm/qsharp/generate_katas_content.js | 9 +- source/vscode/src/gh-copilot/learningTools.ts | 102 ++++++++---------- source/vscode/src/learning/commands.ts | 2 +- source/vscode/src/learning/service.ts | 34 ++++-- 4 files changed, 73 insertions(+), 74 deletions(-) diff --git a/source/npm/qsharp/generate_katas_content.js b/source/npm/qsharp/generate_katas_content.js index 928acef31e..82122e42a0 100644 --- a/source/npm/qsharp/generate_katas_content.js +++ b/source/npm/qsharp/generate_katas_content.js @@ -449,10 +449,11 @@ function createExerciseSection(kataPath, properties, globalCodeSources) { // button instead of the embedded
blocks. const hintPattern = /
\s*[\s\S]*?Need a hint[\s\S]*?<\/summary>([\s\S]*?)<\/details>/gi; - let extractedHints; + /** @type {string[]} */ + let hints = []; if (!emitHtml) { // Capture the inner content of each hint block before stripping. - extractedHints = [...descriptionMarkdown.matchAll(hintPattern)].map((m) => + hints = [...descriptionMarkdown.matchAll(hintPattern)].map((m) => m[1].trim(), ); descriptionMarkdown = descriptionMarkdown.replace(hintPattern, "").trim(); @@ -498,9 +499,7 @@ function createExerciseSection(kataPath, properties, globalCodeSources) { sourceIds, placeholderCode, explainedSolution, - ...(extractedHints && extractedHints.length > 0 - ? { hints: extractedHints } - : {}), + ...(hints.length > 0 ? { hints } : {}), }; } diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts index 4eb8c6c8da..29f454261c 100644 --- a/source/vscode/src/gh-copilot/learningTools.ts +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -33,6 +33,12 @@ export interface SerializedLearningState { }; } +/** + * Mixin carrying the current learning state snapshot. + * Intersected into every tool response type. + */ +export type StateSnapshot = { state: SerializedLearningState }; + /** * Wraps the shared {@link LearningService} singleton for use as * `vscode.lm` language model tools. @@ -102,7 +108,7 @@ export class LearningTools { */ async getState(): Promise< | { initialized: false } - | { initialized: true; state: SerializedLearningState } + | ({ initialized: true } & StateSnapshot) > { if (!this.service.initialized) { const detected = await detectLearningWorkspace(); @@ -117,61 +123,40 @@ export class LearningTools { /** * Return the full per-kata progress breakdown. */ - async getProgress(): Promise<{ - progress: OverallProgress; - state: SerializedLearningState; - }> { + async getProgress(): Promise<{ progress: OverallProgress }> { await this.ensureInitialized(); const progress = this.service.getProgress(); - return { - progress, - state: this.serializeState(), - }; + return { progress }; } /** * List all available units with completion status. */ - async listUnits(): Promise<{ - units: UnitSummary[]; - state: SerializedLearningState; - }> { + async listUnits(): Promise<{ units: UnitSummary[] }> { await this.ensureInitialized(); - return { - units: this.service.listUnits(), - state: this.serializeState(), - }; + return { units: this.service.listUnits() }; } /** * Read the user's current Q# code at the active exercise or example. */ - async readCode(): Promise<{ - code: string; - filePath: string; - state: SerializedLearningState; - }> { + async readCode(): Promise<{ code: string; filePath: string }> { await this.ensureInitialized(); - const uri = this.getCurrentFileUri(); - const isExercise = - this.service.getCurrentActivity().content.type === "exercise"; - const code = isExercise - ? await this.service.readUserCode() - : new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); - return { code, filePath: uri.fsPath, state: this.serializeState() }; + return this.invoke(async () => { + const uri = this.getCurrentFileUri(); + const code = await this.service.readUserCode(); + return { code, filePath: uri.fsPath }; + }); } /** * Return all built-in hints for the current exercise. */ - async hint(): Promise<{ - result: HintContext | null; - state: SerializedLearningState; - }> { + async hint(): Promise<{ result: HintContext | null }> { await this.ensureInitialized(); return this.invoke(() => { const r = this.service.getHintContext("chat"); - return { result: r.result, state: this.serializeState() }; + return { result: r.result }; }); } @@ -180,33 +165,36 @@ export class LearningTools { /** * Show the current learning activity. */ - async show(): Promise<{ state: SerializedLearningState }> { + async show(): Promise { await this.ensureInitialized(); - await this.showActivity(); - return { state: this.serializeState() }; + return this.invoke(async () => { + await this.showActivity(); + return { state: this.serializeState() }; + }); } /** * Move to the next item. */ - async next(): Promise<{ moved: boolean; state: SerializedLearningState }> { + async next(): Promise<{ moved: boolean } & StateSnapshot> { await this.ensureInitialized(); - const r = this.service.next("chat"); - await this.showActivity(); - return { moved: r.moved, state: this.serializeState() }; + return this.invoke(async () => { + const r = await this.service.next("chat"); + await this.showActivity(); + return { moved: r.moved, state: this.serializeState() }; + }); } /** * Move to the previous item. */ - async previous(): Promise<{ - moved: boolean; - state: SerializedLearningState; - }> { + async previous(): Promise<{ moved: boolean } & StateSnapshot> { await this.ensureInitialized(); - const r = this.service.previous("chat"); - await this.showActivity(); - return { moved: r.moved, state: this.serializeState() }; + return this.invoke(async () => { + const r = await this.service.previous("chat"); + await this.showActivity(); + return { moved: r.moved, state: this.serializeState() }; + }); } /** @@ -216,10 +204,10 @@ export class LearningTools { courseId?: string; unitId: string; activityId?: string; - }): Promise<{ state: SerializedLearningState }> { + }): Promise { await this.ensureInitialized(); return this.invoke(async () => { - this.service.goTo(input, "chat"); + await this.service.goTo(input, "chat"); await this.showActivity(); return { state: this.serializeState() }; }); @@ -230,7 +218,7 @@ export class LearningTools { */ async run(input: { shots?: number; - }): Promise<{ result: RunResult; state: SerializedLearningState }> { + }): Promise<{ result: RunResult } & StateSnapshot> { await this.ensureInitialized(); return this.invoke(async () => { const r = await this.service.run(input.shots ?? 1, "chat"); @@ -242,10 +230,7 @@ export class LearningTools { /** * Check the student's solution. Marks it complete on pass. */ - async check(): Promise<{ - result: SolutionCheckResult; - state: SerializedLearningState; - }> { + async check(): Promise<{ result: SolutionCheckResult } & StateSnapshot> { await this.ensureInitialized(); return this.invoke(async () => { const r = await this.service.checkSolution("chat"); @@ -258,7 +243,7 @@ export class LearningTools { * Reset the current exercise to its original placeholder code * and clear its completion status. */ - async resetExercise(): Promise<{ state: SerializedLearningState }> { + async resetExercise(): Promise { await this.ensureInitialized(); return this.invoke(async () => { await this.service.resetExercise("chat"); @@ -270,10 +255,7 @@ export class LearningTools { /** * Show the full reference solution code. */ - async solution(): Promise<{ - result: string; - state: SerializedLearningState; - }> { + async solution(): Promise<{ result: string } & StateSnapshot> { await this.ensureInitialized(); return this.invoke(async () => { const result = this.service.getFullSolution("chat"); diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts index 2d3ef1a1e3..3b16ac65d3 100644 --- a/source/vscode/src/learning/commands.ts +++ b/source/vscode/src/learning/commands.ts @@ -75,7 +75,7 @@ export function registerLearningCommands( return; } - service.goTo(location, "tree"); + await service.goTo(location, "tree"); await panelManager.show(); }, ), diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts index 6feff1daa4..5963da2b52 100644 --- a/source/vscode/src/learning/service.ts +++ b/source/vscode/src/learning/service.ts @@ -203,7 +203,7 @@ export class LearningService { }; } - next(source: TelemetrySource): NavigationResult { + async next(source: TelemetrySource): Promise { const ws = this.requireWorkspace(); const currentPos = ws.progressData.position; const nextPos = this.nextActivity(currentPos); @@ -221,13 +221,13 @@ export class LearningService { } ws.progressData.position = nextPos; - this.saveProgress().catch(() => {}); + await this.saveProgress(); this._onDidChangeState.fire(this.getState()); this.sendActivityActionTelemetry("navigate", source); return { moved: true }; } - previous(source: TelemetrySource): NavigationResult { + async previous(source: TelemetrySource): Promise { const ws = this.requireWorkspace(); const prevPos = this.previousActivity(ws.progressData.position); if (!prevPos) { @@ -235,16 +235,16 @@ export class LearningService { } ws.progressData.position = prevPos; - this.saveProgress().catch(() => {}); + await this.saveProgress(); this._onDidChangeState.fire(this.getState()); this.sendActivityActionTelemetry("navigate", source); return { moved: true }; } - goTo( + async goTo( location: { unitId: string; activityId?: string }, source?: TelemetrySource, - ): LearningState { + ): Promise { const ws = this.requireWorkspace(); const unit = ws.catalog.units.find((u) => u.id === location.unitId); if (!unit || unit.activities.length === 0) { @@ -263,7 +263,7 @@ export class LearningService { unitId: location.unitId, activityId: activity.id, }; - this.saveProgress().catch(() => {}); + await this.saveProgress(); const state = this.getState(); this._onDidChangeState.fire(state); if (source) { @@ -402,11 +402,27 @@ export class LearningService { } async readUserCode(): Promise { - const uri = this.getExerciseFileUri(); + const uri = this.getCurrentCodeFileUri(); + if (!uri) { + throw new Error( + "Current activity has no associated code file.", + ); + } + await this.saveOpenDocument(uri); const bytes = await vscode.workspace.fs.readFile(uri); 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 { + const doc = vscode.workspace.textDocuments.find( + (d) => d.uri.toString() === uri.toString(), + ); + if (doc?.isDirty) { + await doc.save(); + } + } + async markExampleRun(): Promise { const location = this.requireWorkspace().progressData.position; this.markComplete(location); @@ -1014,6 +1030,8 @@ export class LearningService { this.emitProgress(); } + + async reloadProgress(): Promise { const ws = this.requireWorkspace(); await this.loadProgress(ws); From 9a3c55518a71c3de0efa478fe529740c4811dc85 Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 20 May 2026 06:26:04 +0000 Subject: [PATCH 23/26] Add CSP to learning webview with unsafe-inline for KaTeX styles --- source/vscode/src/learning/panel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/vscode/src/learning/panel.ts b/source/vscode/src/learning/panel.ts index a2f232faae..a7b7c7baaf 100644 --- a/source/vscode/src/learning/panel.ts +++ b/source/vscode/src/learning/panel.ts @@ -310,12 +310,12 @@ export class LessonPanelManager { try { switch (action) { case "next": { - const result = this.service.next("panel"); + const result = await this.service.next("panel"); this.sendResult("next", result); break; } case "back": { - const result = this.service.previous("panel"); + const result = await this.service.previous("panel"); this.sendResult("back", result); break; } @@ -359,6 +359,7 @@ export class LessonPanelManager { private getWebviewContent(webview: vscode.Webview): string { const extensionUri = this.extensionUri; + const cspSource = webview.cspSource; function getUri(...parts: string[]): vscode.Uri { return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, ...parts)); @@ -379,6 +380,8 @@ export class LessonPanelManager { + Lesson From 76f731776395feb26a6c5272cfaefa66f61535ca Mon Sep 17 00:00:00 2001 From: Mine Starks Date: Wed, 20 May 2026 07:09:26 +0000 Subject: [PATCH 24/26] Remove unused Action variants (progress, menu, quit) --- source/vscode/src/learning/types.d.ts | 5 +---- source/vscode/src/learning/webview/webview-client.tsx | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/source/vscode/src/learning/types.d.ts b/source/vscode/src/learning/types.d.ts index 6f89be923b..d91e241d86 100644 --- a/source/vscode/src/learning/types.d.ts +++ b/source/vscode/src/learning/types.d.ts @@ -72,10 +72,7 @@ export type Action = | "run" | "check" | "hint-chat" - | "explain-chat" - | "progress" - | "menu" - | "quit"; + | "explain-chat"; export interface ActionBinding { /** Keyboard shortcut key (single character like "b", or "space"). */ diff --git a/source/vscode/src/learning/webview/webview-client.tsx b/source/vscode/src/learning/webview/webview-client.tsx index 232bbb3ac6..1bd3e3a5f6 100644 --- a/source/vscode/src/learning/webview/webview-client.tsx +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -506,15 +506,12 @@ function ActionBar({ return (