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/npm/qsharp/generate_katas_content.js b/source/npm/qsharp/generate_katas_content.js index 854164f175..82122e42a0 100644 --- a/source/npm/qsharp/generate_katas_content.js +++ b/source/npm/qsharp/generate_katas_content.js @@ -439,10 +439,26 @@ 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; + /** @type {string[]} */ + let hints = []; + if (!emitHtml) { + // Capture the inner content of each hint block before stripping. + hints = [...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 +499,7 @@ function createExerciseSection(kataPath, properties, globalCodeSources) { sourceIds, placeholderCode, explainedSolution, + ...(hints.length > 0 ? { hints } : {}), }; } 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..4142536b38 --- /dev/null +++ b/source/vscode/agents/qdk-learning.agent.md @@ -0,0 +1,102 @@ +--- +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 + +Call `hint` and `read-code` together. The response contains `hints` (short nudges, easiest→hardest) and `solutionExplanation` (deeper walkthrough). + +- 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 + +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/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/package.json b/source/vscode/package.json index 4e1bdfcb80..803f40503d 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -313,6 +313,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": [ @@ -325,6 +353,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": [ @@ -370,6 +413,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": [ @@ -387,13 +440,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": [ @@ -415,6 +489,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", @@ -569,6 +653,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": [ @@ -1087,6 +1213,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 + } } ] }, @@ -1098,7 +1492,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" }, 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. 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); diff --git a/source/vscode/src/gh-copilot/learningTools.ts b/source/vscode/src/gh-copilot/learningTools.ts new file mode 100644 index 0000000000..f3996fd9e1 --- /dev/null +++ b/source/vscode/src/gh-copilot/learningTools.ts @@ -0,0 +1,323 @@ +// 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; + }; +} + +/** + * 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. + */ +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.tryInitialize({ 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 } & StateSnapshot) + > { + 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 }> { + await this.ensureInitialized(); + const progress = this.service.getProgress(); + return { progress }; + } + + /** + * List all available units with completion status. + */ + async listUnits(): Promise<{ units: UnitSummary[] }> { + await this.ensureInitialized(); + 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 }> { + await this.ensureInitialized(); + 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 }> { + await this.ensureInitialized(); + return this.invoke(() => { + const r = this.service.getHintContext("chat"); + return { result: r.result }; + }); + } + + // ─── Navigation & actions (open the panel) ─── + + /** + * Show the current learning activity. + */ + async show(): Promise { + await this.ensureInitialized(); + return this.invoke(async () => { + await this.showActivity(); + return { state: this.serializeState() }; + }); + } + + /** + * Move to the next item. + */ + async next(): Promise<{ moved: boolean } & StateSnapshot> { + await this.ensureInitialized(); + 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 } & StateSnapshot> { + await this.ensureInitialized(); + return this.invoke(async () => { + const r = await 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 { + await this.ensureInitialized(); + return this.invoke(async () => { + await this.service.goTo(input, "chat"); + await this.showActivity(); + return { state: this.serializeState() }; + }); + } + + /** + * Run the Q# code at the current position. + */ + async run(input: { + shots?: number; + }): Promise<{ result: RunResult } & StateSnapshot> { + await this.ensureInitialized(); + return this.invoke(async () => { + 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 } & StateSnapshot> { + await this.ensureInitialized(); + return this.invoke(async () => { + 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 { + await this.ensureInitialized(); + return this.invoke(async () => { + await this.service.resetExercise("chat"); + await this.showActivity(); + return { state: this.serializeState() }; + }); + } + + /** + * Show the full reference solution code. + */ + async solution(): Promise<{ result: string } & StateSnapshot> { + await this.ensureInitialized(); + 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"); + } + + private getCurrentFileUri(): vscode.Uri { + const uri = this.service.getCurrentCodeFileUri(); + if (!uri) { + throw new CopilotToolError( + "Current activity is not an exercise or example — there is no code to read.", + ); + } + return uri; + } + + /** + * 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..11ab813a24 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, }; } @@ -168,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."; } } diff --git a/source/vscode/src/learning/catalog.ts b/source/vscode/src/learning/catalog.ts new file mode 100644 index 0000000000..f64dfea9be --- /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, + CatalogActivity, + 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, + activities: 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 }; +} 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", + }), + ]; + }, + }; +} diff --git a/source/vscode/src/learning/commands.ts b/source/vscode/src/learning/commands.ts new file mode 100644 index 0000000000..3b16ac65d3 --- /dev/null +++ b/source/vscode/src/learning/commands.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import { LessonPanelManager } 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, + panelManager: LessonPanelManager, +): void { + context.subscriptions.push( + vscode.commands.registerCommand("qsharp-vscode.learningShowActivity", () => + panelManager.show(), + ), + + // Code lens commands + + vscode.commands.registerCommand( + "qsharp-vscode.learningCheckSolution", + async () => { + await panelManager.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; + } + + await service.goTo(location, "tree"); + await panelManager.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, + }; + } + } +} diff --git a/source/vscode/src/learning/constants.ts b/source/vscode/src/learning/constants.ts new file mode 100644 index 0000000000..e885f1936b --- /dev/null +++ b/source/vscode/src/learning/constants.ts @@ -0,0 +1,21 @@ +// 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"; + +/** Tree view ID for the learning progress panel. */ +export const LEARNING_TREE_VIEW_ID = "qsharp-vscode.learningTree"; 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..8b31a1fc72 --- /dev/null +++ b/source/vscode/src/learning/panel.ts @@ -0,0 +1,408 @@ +// 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, LEARNING_TREE_VIEW_ID } from "./constants.js"; +import type { LearningService } from "./service.js"; +import type { TelemetrySource } from "./types.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.learningContentRoot, + ], + }, + ); + + 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.learningContentRoot.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(`${LEARNING_TREE_VIEW_ID}.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 = await this.service.next("panel"); + this.sendResult("next", result); + break; + } + case "back": { + const result = await 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.getCurrentActivity(); + if (pos.content.type !== "lesson-example") { + throw new Error("Current item cannot be run."); + } + + const fileUri = this.service.getExampleFileUri(); + await this.service.markExampleRun(); + + await this.openCurrentCodeEditor(); + await vscode.commands.executeCommand( + `${qsharpExtensionId}.runProgram`, + fileUri, + ); + } + + 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)); + } + + const webviewClientJsUri = getUri( + "out", + "learning", + "webview", + "webview-client.js", + ); + 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"); + + 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; + } +} diff --git a/source/vscode/src/learning/progressTreeView.ts b/source/vscode/src/learning/progressTreeView.ts new file mode 100644 index 0000000000..1e6c938d03 --- /dev/null +++ b/source/vscode/src/learning/progressTreeView.ts @@ -0,0 +1,269 @@ +// 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"; +import { LEARNING_TREE_VIEW_ID } from "./constants.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(LEARNING_TREE_VIEW_ID, { + 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 = node.kind; + 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 = node.kind; + 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; +} diff --git a/source/vscode/src/learning/service.ts b/source/vscode/src/learning/service.ts new file mode 100644 index 0000000000..06ec0f0a2c --- /dev/null +++ b/source/vscode/src/learning/service.ts @@ -0,0 +1,1172 @@ +// 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(), + }; + } + + async next(source: TelemetrySource): Promise { + 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; + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + + async previous(source: TelemetrySource): Promise { + const ws = this.requireWorkspace(); + const prevPos = this.previousActivity(ws.progressData.position); + if (!prevPos) { + return { moved: false }; + } + + ws.progressData.position = prevPos; + await this.saveProgress(); + this._onDidChangeState.fire(this.getState()); + this.sendActivityActionTelemetry("navigate", source); + return { moved: true }; + } + + async goTo( + location: { unitId: string; activityId?: string }, + source?: TelemetrySource, + ): Promise { + 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, + }; + await this.saveProgress(); + 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.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); + 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 = { + 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 { + 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(); + } + + 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..d91e241d86 --- /dev/null +++ b/source/vscode/src/learning/types.d.ts @@ -0,0 +1,275 @@ +// 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"; + +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/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..1bd3e3a5f6 --- /dev/null +++ b/source/vscode/src/learning/webview/webview-client.tsx @@ -0,0 +1,601 @@ +// 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"; +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"; +import markdownIt from "markdown-it"; +import type { + CurrentActivity, + ActionGroup, + OverallProgress, + ActivityContent, + ActivityLocation, + SolutionCheckResult, + 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 || locationKey(p.location) !== locationKey(n.location)) { + 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 }; + } +} + +function locationKey(loc: ActivityLocation): string { + return `${loc.courseId}__${loc.unitId}__${loc.activityId}`; +} + +// ─── 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" + ? "out-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({ messages }: { messages: string[] }) { + return ( + <> + {messages.map((msg, i) => ( +
+ {msg} +
+ ))} + + ); +} + +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 ? ( + <> + {currentUnit.title} + + {currentUnit.activities.map((act) => { + const isCurrent = act.id === currentPosition!.activityId; + const cls = + "pb-seg" + + (act.isComplete ? " done" : isCurrent ? " current" : ""); + return ; + })} + + + ) : currentPosition && currentPosition.unitId ? ( + {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..80a2eb2770 --- /dev/null +++ b/source/vscode/src/learning/webview/webview.css @@ -0,0 +1,491 @@ +/* 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); + /* 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)); + /* 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); + /* 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, + 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, + -apple-system, + "Segoe UI", + sans-serif + ); + /* Monospace font for inline code and fenced code blocks */ + --mono-font: var( + --vscode-editor-font-family, + ui-monospace, + "Cascadia Mono", + Menlo, + Monaco, + 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(--body-font-size) / 1.45 var(--ui-font); + margin: 0; + background: var(--bg); + color: var(--fg); + display: flex; + flex-direction: column; + height: 100%; +} + +/* Logo and text at the top of the panel. */ +.branding { + display: flex; + align-items: center; + 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; +} + +/* 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: var(--font-sm); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--muted); +} + +/* Breadcrumb bar showing "Unit › Activity" and a type badge. */ +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + 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; + 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: #fff; +} + +/* Main scrollable area holding rendered Markdown (lessons, examples, exercises). */ +.content { + 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: 6px 0 5px; +} + +.content h1 { + font-size: var(--font-h1); +} + +.content h2 { + font-size: var(--font-h2); +} + +.content h3 { + font-size: var(--font-h3); +} + +/* Markdown paragraphs. */ +.content p { + margin: 6px 0; +} + +/* Markdown lists. */ +.content ul, +.content ol { + margin: 6px 0; + padding-left: 21px; +} + +/* Fenced code blocks. */ +.content pre { + background: var(--code-bg); + 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: 4px 0; + font-size: var(--font-sm); +} + +.content th, +.content td { + border: 1px solid var(--border); + padding: 2px 6px; + text-align: left; /* override default center-align on */ +} + +/* Small muted note below content: "Your code file should be open…" */ +.content .file-path { + font-size: var(--font-xs); + color: var(--muted); + 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; +} + +.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: 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: var(--font-sm); + font-weight: 600; +} + +/* Used by: — the "✓" checkmark inside the completion banner. */ +.completion-banner .completion-icon { + font-size: var(--font-icon); +} + +/* Result/output card between content and action bar. Color-coded left border + indicates status (accent = neutral, green = pass, red = fail). */ +.output { + margin: 0 16px 8px; + padding: 6px 10px; + border-radius: var(--radius); + background: var(--code-bg); + font-size: var(--font-sm); + border-left: 3px solid var(--accent); + 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: var(--font-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 4px; +} + +/* "×" dismiss button pinned to the top-right corner. */ +.output .out-dismiss { + position: absolute; + top: 4px; + right: 4px; + background: none; /* reset UA button style */ + border: none; /* reset UA button style */ + color: var(--muted); + cursor: pointer; + 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 .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; +} + +/* 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 { + font-family: var(--mono-font); + white-space: pre-wrap; +} + +/* Bottom toolbar with grouped action buttons (Back, Next, Check, Run, Hint…). */ +.action-bar { + display: flex; + flex-wrap: wrap; + 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: 4px; +} + +.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; /* 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: 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: not-allowed; +} + +/* Clickable footer showing completion stats and activity dots. + Opens the progress tree view on click. */ +.progress-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + 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: 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); +} + +/* "What went wrong?" link — opens Copilot Chat with a help prompt. */ +.chat-link { + display: inline-block; + 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; +} 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..8be53492aa 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: "true" | "false"; + }; + 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 { 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"] }