diff --git a/docs/architecture/ai-hints.md b/docs/architecture/ai-hints.md index 22a5e4a..0e3fdcd 100644 --- a/docs/architecture/ai-hints.md +++ b/docs/architecture/ai-hints.md @@ -69,8 +69,7 @@ Adding a provider is one spec; settings, IPC and the settings UI read the regist - **The model is not a setting, except for Custom.** Each named provider always uses its spec's default model, and any model saved earlier is ignored. Students can't judge which model tutors - well, and a free-text model field let a saved `openrouter/free` bypass the routing below. Custom - has no sensible default, so it keeps a required Model field. + well. Custom has no sensible default, so it keeps a required Model field. - **Per-provider settings.** `config.ai.providers[id]` stores the key (plus base URL and model for Custom) for each provider separately, so switching providers doesn't discard a key already entered. @@ -87,48 +86,40 @@ Adding a provider is one spec; settings, IPC and the settings UI read the regist The earlier OpenRouter-only key field (`openRouterApiKeyEnc`) was dropped without migration. The feature had not shipped. -### OpenRouter model routing +### OpenRouter model -The default provider has to work with a free key, and free models are the least reliable part of -the whole feature. Each request sends a primary `model` plus OpenRouter's `models` fallback array, -and OpenRouter tries them in order, moving on when one is down, rate-limited, or has left the free -tier: +The default provider has to work with a free key. Every request uses `openrouter/free`, OpenRouter's +router over all zero-cost models, and sends no fallback list. -1. `google/gemma-4-31b-it:free` (primary) -2. `qwen/qwen3.8-27b:free` -3. `nvidia/nemotron-3-super-120b-a12b:free` -4. `openrouter/free` (last resort) +**Why the router alone.** Free-tier membership changes without notice (see `../llm-integration.md`, +Option 9), so any named free model eventually disappears or is rate-limited, and a hard-coded list +needs upkeep with every change. The router always resolves to something that is currently free, so +the default keeps working with nothing to maintain. -The three fallbacks are the most OpenRouter is documented to accept (its equivalent `fallbacks` -parameter caps at three). +**The cost is quality.** The router picks from every zero-cost model, and some can't tutor. In +testing it produced: -**Why named models come first.** On its own, `openrouter/free` picks from every zero-cost model, and -in testing it produced unusable replies: - -- a ~2B agent-tuned model (`liquid/lfm-2.5-2.6b`) answered in its raw tool-call syntax +- a ~2B agent-tuned model (`liquid/lfm-2.5-2.6b`) answering in its raw tool-call syntax (`<|tool_call_start|>[read(filePath=…)]<|tool_call_end|>`), because it wanted to read files and no tools were offered; -- a content-safety classifier (`nvidia/nemotron-3.5-content-safety`) answered "User Safety: safe". - -The named models are mid-sized, instruction-tuned, and follow the hint policy reasonably well. +- a content-safety classifier (`nvidia/nemotron-3.5-content-safety`) answering "User Safety: safe". -**Why `openrouter/free` is still the last resort.** The tradeoff is availability against quality. -Free models are rate-limited per model, and at busy times all three named models can be throttled -together. Without the router, the student gets a rate-limit error and has to wait. With it, they -usually still get an answer, but occasionally from a model that can't tutor. Availability wins: the -feature is only useful if it answers, and a bad reply is visible and easy to retry. - -What limits the damage when the router picks badly: +What limits the damage: - The system prompt says the model has no tools and must reply only in text. Weaker agent-tuned models then answer in prose more often, but not reliably. -- **Regenerate** retries the whole chain. That lands on a named model once its rate limit has reset, - or on a different random model if it hasn't. +- **Regenerate** sends the request again, which usually lands on a different model. - The classifier and code-only models can't be steered by the prompt. When they answer, the reply is obviously wrong rather than subtly misleading, which is the lesser failure for a tutor. +- Students who want consistent answers can switch to a paid provider, or to Google's free tier, in + Settings. **Alternatives rejected:** +- **Named free models first, with `openrouter/free` as the last fallback.** This was the previous + setup (a primary model plus three fallbacks, the most OpenRouter accepts). It gave better replies + while the named models stayed free, but they had to be checked and replaced by hand whenever they + left the free tier or were throttled. - **Stripping tool-call tokens from the output.** This treats one model family's symptom, and the answer underneath still comes from a model too small to tutor. - **Choosing a free model at runtime from `/models`.** The listing has price and context length but @@ -136,11 +127,6 @@ What limits the damage when the router picks badly: - **A paid default model.** It would be reliable, but every student would need a funded account, which is what the free default exists to avoid. -**Upkeep.** Free-tier membership changes without notice (see `../llm-integration.md`, Option 9). -When replies start coming from the router regularly, check the named models against -`https://openrouter.ai/api/v1/models` and replace any that have left the free tier. The response's -`model` field shows which model actually answered. - ## 5. Tutoring policy `ai/prompt.ts` builds the system prompt. The policy depends on the session kind: diff --git a/docs/architecture/exercises-root.md b/docs/architecture/exercises-root.md index 1f64ab4..ecbb62b 100644 --- a/docs/architecture/exercises-root.md +++ b/docs/architecture/exercises-root.md @@ -12,13 +12,13 @@ needed. Running setup from an onboarding screen asked for that work before the lesson did, and it required the CLI to be installed first. The first time a learner clicks Start, the app walks through three steps. The -introduction is shown once. The other two are checked on every Start, from the -main process, so the Start button in the app and the one in the lesson page -behave the same. +introduction repeats on every Start until they check "Don't show this again". +The other two are checked on every Start, from the main process, so the Start +button in the app and the one in the lesson page behave the same. 1. **Introduction.** A short description of an exercise: a folder of starting files, work in the terminal, then Verify. Hands-on practicals are mentioned - because they have no Verify step. + because they have no Verify step. A checkbox opts out of seeing it again. 2. **Tools.** `gitmastery` must be on PATH. On Windows, Git Bash must be installed too, because the in-app terminal uses it. Neither check runs the CLI. On Windows the step says to restart the app, because PATH is read at diff --git a/docs/architecture/walkthrough.md b/docs/architecture/walkthrough.md new file mode 100644 index 0000000..ebe354b --- /dev/null +++ b/docs/architecture/walkthrough.md @@ -0,0 +1,42 @@ +# App walkthrough + +A two-step tour runs on first launch and whenever the learner clicks Help (the +question mark in the header). Each step dulls everything except one area and +explains it; the learner clicks Next to move on. There is no skip: the tour is +two clicks long. After the last step the app behaves as normal. + +1. **Lessons.** The terminal column and header are dimmed. +2. **Terminal.** The lesson page, tours panel and header are dimmed, and the + terminal gets a brand outline. + +"Seen" lives in renderer `localStorage` (`gm-walkthrough-seen`). It is a +display preference with no meaning to the main process, like the other +renderer-only flags. + +## The lesson page is dimmed from inside the page + +The lesson site is a native `WebContentsView` that paints above all DOM, so a +React overlay cannot dull it. For step 2 the renderer sends `wcv-set-dimmed`, +and main injects a fixed, full-page layer into the lesson page itself. The +colour mirrors the `--gm-dim` token for the current theme, so it matches the +dimmed DOM panes. Main keeps the flag and re-applies it on `dom-ready`, so the +dim survives a navigation or a page that finishes loading mid-tour. The layer +also swallows clicks, so the learner cannot wander off during the step. + +## Cards always sit in the terminal column + +For the same reason, a card placed over the lesson pane would be hidden. Both +step cards render inside the terminal column: centred over the dimmed terminal +in step 1, and along the bottom of the terminal in step 2. They are compact +callouts, so the title is Inter semibold like a toast rather than the serif +modal title. + +## Rejected + +- **Hide the native view for step 2.** Simple, but the lesson pane goes blank + instead of dull, and the learner loses the sense of where the lessons are. +- **A tour library (spotlight / popover).** Such libraries position DOM + popovers and cutouts, which cannot reach the native view. +- **Render the card inside the lesson page.** It could sit anywhere, but its + buttons would need a round trip through the page bridge and main, and it + would have to re-implement the house styles in injected CSS. diff --git a/src/electron/ai/llmProviders.ts b/src/electron/ai/llmProviders.ts index 12954ef..623d285 100644 --- a/src/electron/ai/llmProviders.ts +++ b/src/electron/ai/llmProviders.ts @@ -31,19 +31,10 @@ export type ProviderSpec = AiProviderInfo & { const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; /** - * Named free models first; OpenRouter moves down the list when one is down, - * rate-limited, or no longer free. `openrouter/free` is the last resort and - * picks any zero-cost model, some of which cannot tutor — see - * docs/architecture/ai-hints.md. Free-tier membership changes without notice; - * revisit the named models when they stop answering. + * OpenRouter's router over every zero-cost model. Some of those cannot tutor — + * see docs/architecture/ai-hints.md. */ -const OPENROUTER_PRIMARY_MODEL = "google/gemma-4-31b-it:free"; -/** At most three: OpenRouter caps the equivalent `fallbacks` list at three. */ -const OPENROUTER_FALLBACK_MODELS = [ - "qwen/qwen3.8-27b:free", - "nvidia/nemotron-3-super-120b-a12b:free", - "openrouter/free", -]; +const OPENROUTER_FREE_MODEL = "openrouter/free"; /** Optional OpenRouter attribution headers. */ const OPENROUTER_HEADERS = { @@ -109,7 +100,7 @@ const SPECS: ProviderSpec[] = [ label: "OpenRouter", description: "Free models with a free account. Recommended if you don't already pay for an AI API.", - defaultModel: OPENROUTER_PRIMARY_MODEL, + defaultModel: OPENROUTER_FREE_MODEL, keyUrl: "https://openrouter.ai/keys", keyPlaceholder: "sk-or-v1-…", keyRequired: true, @@ -127,7 +118,6 @@ const SPECS: ProviderSpec[] = [ probe("OpenRouter", `${OPENROUTER_BASE_URL}/key`, { Authorization: `Bearer ${apiKey}`, }), - providerOptions: { openrouter: { models: OPENROUTER_FALLBACK_MODELS } }, }, { id: "openai", diff --git a/src/electron/ipc/gitmastery.ts b/src/electron/ipc/gitmastery.ts index a4af48c..05b2646 100644 --- a/src/electron/ipc/gitmastery.ts +++ b/src/electron/ipc/gitmastery.ts @@ -34,6 +34,23 @@ const START_EXERCISE_STARTED_CHANNEL = "start-exercise-started" as const; const START_EXERCISE_RESULT_CHANNEL = "start-exercise-result" as const; const VERIFY_BLOCKED_CHANNEL = "verify-blocked" as const; +type ExercisePageBusy = ( + kind: "start" | "verify", + id: string, + busy: boolean, +) => void; + +let exercisePageBusy: ExercisePageBusy | null = null; + +/** The embedded lesson page paints Start/Verify busy state from this. */ +export function onExercisePageBusy(handler: ExercisePageBusy) { + exercisePageBusy = handler; +} + +function setPageBusy(kind: "start" | "verify", id: string, busy: boolean) { + exercisePageBusy?.(kind, id, busy); +} + const isSameDirectory = (a: string, b: string): boolean => { const left = path.resolve(a); const right = path.resolve(b); @@ -292,121 +309,83 @@ export const _download = ( export const _verify = ( mainWindow: BrowserWindow, exerciseIdentifier: string, -) => { - const blocking = getBlockingPrereq(); - if (blocking) { - const message = prereqFailureMessage(blocking); - echoToXterm(`\r\n${message}\r\n`); - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: { - exerciseIdentifier, - completed: { status: "failure", message }, - }, - }); - return; - } - - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: { - exerciseIdentifier, - success: { - message: "Verifying…", - data: { stderr: "", stdout: "" }, - }, - }, +): Promise => { + setPageBusy("verify", exerciseIdentifier, true); + return runVerify(mainWindow, exerciseIdentifier).finally(() => { + setPageBusy("verify", exerciseIdentifier, false); }); +}; - const exerciseCwd = resolveReadyExerciseCwd(exerciseIdentifier); - if (exerciseCwd === null || !isSameDirectory(getCwd(), exerciseCwd)) { - sendToRenderer(mainWindow, VERIFY_BLOCKED_CHANNEL, { exerciseIdentifier }); - return; - } - - const childProcess = _spawnChildProcess({ - args: ["verify"], - cwd: exerciseCwd, - }); - const echo = startCliEcho("gitmastery verify"); - - let stdoutBuffer = ""; - let stderrBuffer = ""; - - childProcess.stdout.on("data", (data) => { - stdoutBuffer += data.toString() + "[[terminal-line]]"; - // Send progress updates to renderer - logGM("stdout", `verify`, data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - exerciseIdentifier: exerciseIdentifier, +const runVerify = ( + mainWindow: BrowserWindow, + exerciseIdentifier: string, +): Promise => + new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; - success: { - message: data.toString(), + const blocking = getBlockingPrereq(); + if (blocking) { + const message = prereqFailureMessage(blocking); + echoToXterm(`\r\n${message}\r\n`); + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, data: { - stdout: stdoutBuffer, - stderr: stderrBuffer, + exerciseIdentifier, + completed: { status: "failure", message }, }, - }, - }; + }); + finish(); + return; + } + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { originalCommand: `verify`, - data: taskPayload, + data: { + exerciseIdentifier, + success: { + message: "Verifying…", + data: { stderr: "", stdout: "" }, + }, + }, }); - // check for SUCCESS and ERROR - }); - - childProcess.stderr.on("data", (data) => { - stderrBuffer += data.toString() + "[[terminal-line]]"; - // Send error updates to renderer - logGM("stderr", `verify`, data.toString()); - echo.write(data.toString()); - - const taskPayload: GitMasteryTaskData = { - exerciseIdentifier: exerciseIdentifier, + const exerciseCwd = resolveReadyExerciseCwd(exerciseIdentifier); + if (exerciseCwd === null || !isSameDirectory(getCwd(), exerciseCwd)) { + sendToRenderer(mainWindow, VERIFY_BLOCKED_CHANNEL, { + exerciseIdentifier, + }); + finish(); + return; + } - error: { - code: 500, // TODO: set this code properly - message: data.toString(), - }, - }; - sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { - originalCommand: `verify`, - data: taskPayload, + const childProcess = _spawnChildProcess({ + args: ["verify"], + cwd: exerciseCwd, }); - }); + const echo = startCliEcho("gitmastery verify"); - childProcess.on("error", (err) => { - echo.write(spawnFailureMessage(err)); - echo.finish(); - _reportSpawnFailure(mainWindow, "verify", exerciseIdentifier, err); - }); + let stdoutBuffer = ""; + let stderrBuffer = ""; - childProcess.on("close", (code) => { - echo.finish(); - logGM("close", `verify`, String(code)); - if (code === 0) { - // Success - - const correct = _checkCorrectSolution(stdoutBuffer); - const incorrect = _checkIncorrectSolution(stdoutBuffer); - const comments = _getComments(stdoutBuffer); + childProcess.stdout.on("data", (data) => { + stdoutBuffer += data.toString() + "[[terminal-line]]"; + // Send progress updates to renderer + logGM("stdout", `verify`, data.toString()); + echo.write(data.toString()); const taskPayload: GitMasteryTaskData = { exerciseIdentifier: exerciseIdentifier, - completed: { - status: "success", - message: "Verify finished", - stdout: stdoutBuffer, - stderr: stderrBuffer, - + success: { + message: data.toString(), data: { - correct, - incorrect, - comments, + stdout: stdoutBuffer, + stderr: stderrBuffer, }, }, }; @@ -415,32 +394,95 @@ export const _verify = ( data: taskPayload, }); - if (!exerciseIdentifier.startsWith(HANDS_ON_PREFIX)) { - patchExerciseProgress( - exerciseIdentifier, - correct ? "completed" : "in-progress", - ); - } - } else { - // Failure + // check for SUCCESS and ERROR + }); + + childProcess.stderr.on("data", (data) => { + stderrBuffer += data.toString() + "[[terminal-line]]"; + // Send error updates to renderer + logGM("stderr", `verify`, data.toString()); + echo.write(data.toString()); const taskPayload: GitMasteryTaskData = { exerciseIdentifier: exerciseIdentifier, - completed: { - status: "failure", - message: stderrBuffer || "Verify failed. Try again.", - stdout: stdoutBuffer, - stderr: stderrBuffer, + error: { + code: 500, // TODO: set this code properly + message: data.toString(), }, }; sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { originalCommand: `verify`, data: taskPayload, }); - } + }); + + childProcess.on("error", (err) => { + echo.write(spawnFailureMessage(err)); + echo.finish(); + _reportSpawnFailure(mainWindow, "verify", exerciseIdentifier, err); + finish(); + }); + + childProcess.on("close", (code) => { + if (settled) return; + echo.finish(); + logGM("close", `verify`, String(code)); + if (code === 0) { + // Success + + const correct = _checkCorrectSolution(stdoutBuffer); + const incorrect = _checkIncorrectSolution(stdoutBuffer); + const comments = _getComments(stdoutBuffer); + + const taskPayload: GitMasteryTaskData = { + exerciseIdentifier: exerciseIdentifier, + + completed: { + status: "success", + message: "Verify finished", + stdout: stdoutBuffer, + stderr: stderrBuffer, + + data: { + correct, + incorrect, + comments, + }, + }, + }; + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, + data: taskPayload, + }); + + if (!exerciseIdentifier.startsWith(HANDS_ON_PREFIX)) { + patchExerciseProgress( + exerciseIdentifier, + correct ? "completed" : "in-progress", + ); + } + } else { + // Failure + + const taskPayload: GitMasteryTaskData = { + exerciseIdentifier: exerciseIdentifier, + + completed: { + status: "failure", + message: stderrBuffer || "Verify failed. Try again.", + stdout: stdoutBuffer, + stderr: stderrBuffer, + }, + }; + sendToRenderer(mainWindow, GM_TASK_DATA_CHANNEL, { + originalCommand: `verify`, + data: taskPayload, + }); + } + finish(); + }); }); -}; /** Incremented on every Start so a finishing download cannot steal a newer `cd`. */ let startGeneration = 0; @@ -448,6 +490,7 @@ let startGeneration = 0; const _startExercise = async ( mainWindow: BrowserWindow, exerciseIdentifier: string, + options?: { skipIntro?: boolean }, ): Promise => { // The outcome is broadcast as well as returned, so that the button injected // into the embedded lesson page, which has no return value to inspect, drives @@ -466,7 +509,7 @@ const _startExercise = async ( // Checked before taking a start generation, so a missing tool does not // cancel a download that is already changing directory. - const firstRunStep = getStartPrereqStep(); + const firstRunStep = getStartPrereqStep({ skipIntro: options?.skipIntro }); if (firstRunStep) { return report({ ok: false, exerciseIdentifier, firstRunStep }); } @@ -571,6 +614,7 @@ const startingExercises = new Map>(); export const startExercise = ( mainWindow: BrowserWindow, exerciseIdentifier: string, + options?: { skipIntro?: boolean }, ): Promise => { const inFlight = startingExercises.get(exerciseIdentifier); if (inFlight) return inFlight; @@ -578,10 +622,16 @@ export const startExercise = ( sendToRenderer(mainWindow, START_EXERCISE_STARTED_CHANNEL, { exerciseIdentifier, }); - const started = _startExercise(mainWindow, exerciseIdentifier).finally(() => { + setPageBusy("start", exerciseIdentifier, true); + const started = _startExercise( + mainWindow, + exerciseIdentifier, + options, + ).finally(() => { startingExercises.delete(exerciseIdentifier); // A download is what makes an exercise's AI Hints button usable. notifyAiHintsPageStateChanged(); + setPageBusy("start", exerciseIdentifier, false); }); startingExercises.set(exerciseIdentifier, started); return started; @@ -607,7 +657,7 @@ export function setupGitmasteryIpc(mainWindow: BrowserWindow) { void startExercise(mainWindow, commandArgs.join(" ")); break; case "verify": - _verify(mainWindow, commandArgs.join(" ")); + void _verify(mainWindow, commandArgs.join(" ")); break; default: throw new Error("Invalid command"); @@ -620,8 +670,13 @@ export function setupGitmasteryIpc(mainWindow: BrowserWindow) { // Command 2: `start` an exercise manually (this function helps the user CD into an exercise) ipcMainHandle( "gitmastery-start-exercise", - async ({ exerciseIdentifier }: { exerciseIdentifier: string }) => - startExercise(mainWindow, exerciseIdentifier), + async ({ + exerciseIdentifier, + skipIntro, + }: { + exerciseIdentifier: string; + skipIntro?: boolean; + }) => startExercise(mainWindow, exerciseIdentifier, { skipIntro }), ); } diff --git a/src/electron/ipc/webContentsView.ts b/src/electron/ipc/webContentsView.ts index c700eeb..8073068 100644 --- a/src/electron/ipc/webContentsView.ts +++ b/src/electron/ipc/webContentsView.ts @@ -6,7 +6,7 @@ import { } from "electron"; import { ipcMainHandle, ipcMainOn } from "../utils/util.js"; import { getWcvPreloadPath } from "../pathResolver.js"; -import { startExercise, _verify } from "./gitmastery.js"; +import { startExercise, _verify, onExercisePageBusy } from "./gitmastery.js"; import { getMainWindow } from "../main.js"; import { sendToRenderer } from "./ipcUtils.js"; import { @@ -61,6 +61,52 @@ let sitePrefs: SiteViewPrefs | null = null; let aiHintsHandler: ((exerciseId: string) => void) | null = null; +/** Mirrors `--gm-dim` in src/ui/index.css, so the lesson dims like the DOM panes. */ +const PAGE_DIM_COLOR = { + light: "rgb(23 23 23 / 0.45)", + dark: "rgb(0 0 0 / 0.6)", +} as const; + +/** Set while the walkthrough points at the terminal. Survives navigation. */ +let pageDimmed = false; + +function applyPageDim() { + if (!wcv || wcv.webContents.isDestroyed() || !hasLoadedPage()) return; + const color = PAGE_DIM_COLOR[getAppliedResolvedTheme()]; + void wcv.webContents + .executeJavaScript( + `(function (dimmed, color) { + var el = document.getElementById("gm-walkthrough-dim"); + if (!dimmed) { + if (el) el.remove(); + return; + } + if (!el) { + el = document.createElement("div"); + el.id = "gm-walkthrough-dim"; + (document.body || document.documentElement).appendChild(el); + } + el.style.cssText = "position:fixed; inset:0; z-index:2147483647; background:" + color + ";"; + })(${JSON.stringify(pageDimmed)}, ${JSON.stringify(color)})`, + ) + .catch(() => {}); +} + +const busyStarts = new Set(); +const busyVerifies = new Set(); + +function setPageBusy(kind: "start" | "verify", id: string, busy: boolean) { + const set = kind === "start" ? busyStarts : busyVerifies; + if (busy) set.add(id); + else set.delete(id); + if (!wcv || wcv.webContents.isDestroyed()) return; + void wcv.webContents + .executeJavaScript( + `if (typeof window.__gmSetBusy === "function") window.__gmSetBusy(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(busy)});`, + ) + .catch(() => {}); +} + /** Exercise identifiers are a path segment on disk and a selector in the page. */ const EXERCISE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/i; @@ -220,6 +266,7 @@ function getOrCreateWcv(mainWindow: BrowserWindow): WebContentsView { await applySitePrefsToPage(); await wcv!.webContents.insertCSS(EMBED_CSS).catch(() => {}); })(); + applyPageDim(); }); wcv.webContents.on("did-fail-load", () => { setLoading(mainWindow, false); @@ -258,7 +305,7 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { } else if (channel === "wcv-verify-exercise") { const { exerciseId } = args[0] as { exerciseId: string }; console.log("[wcv] verify exercise clicked:", exerciseId); - _verify(mainWindow, exerciseId); + void _verify(mainWindow, exerciseId); } else if (channel === "wcv-ai-hints") { const { exerciseId } = args[0] as { exerciseId: unknown }; console.log("[wcv] ai hints clicked:", exerciseId); @@ -291,20 +338,77 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { var ICON_DOWNLOAD = ''; var ICON_CHECK = ''; var ICON_SPARKLES = ''; + var ICON_SPINNER = ''; var AI_STATE = ${JSON.stringify(getAiHintsPageState())}; + var BUSY_STATE = ${JSON.stringify({ + start: [...busyStarts], + verify: [...busyVerifies], + })}; var BASE_STYLE = "display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:6px; font-size:14px; font-weight:500; font-family:Inter,system-ui,sans-serif; cursor:pointer; line-height:20px;"; + if (!document.getElementById("gm-busy-style")) { + var spinStyle = document.createElement("style"); + spinStyle.id = "gm-busy-style"; + spinStyle.textContent = "@keyframes gm-spin { to { transform: rotate(360deg); } }"; + document.head.appendChild(spinStyle); + } + + window.__gmBusyState = { start: {}, verify: {} }; + BUSY_STATE.start.forEach(function (id) { window.__gmBusyState.start[id] = true; }); + BUSY_STATE.verify.forEach(function (id) { window.__gmBusyState.verify[id] = true; }); + + function setButtonBusy(btn, busy) { + if ((btn.dataset.gmBusy === "true") === busy) return; + btn.dataset.gmBusy = busy ? "true" : "false"; + btn.disabled = busy; + btn.setAttribute("aria-busy", busy ? "true" : "false"); + btn.style.opacity = busy ? "0.7" : "1"; + btn.style.cursor = busy ? "progress" : "pointer"; + if (busy) { + btn.dataset.gmIdleHtml = btn.innerHTML; + var label = btn.querySelector("span"); + btn.innerHTML = ICON_SPINNER + (label ? label.outerHTML : ""); + } else if (btn.dataset.gmIdleHtml) { + btn.innerHTML = btn.dataset.gmIdleHtml; + delete btn.dataset.gmIdleHtml; + } + } + + window.__gmSetBusy = function (kind, id, busy) { + if (!window.__gmBusyState[kind]) window.__gmBusyState[kind] = {}; + window.__gmBusyState[kind][id] = busy; + var attr = kind === "verify" ? "data-gm-verify" : "data-gm-start"; + document.querySelectorAll("[" + attr + '="' + CSS.escape(id) + '"]').forEach(function (btn) { + setButtonBusy(btn, busy); + }); + }; + + function applyBusyState() { + Object.keys(window.__gmBusyState.start || {}).forEach(function (id) { + if (window.__gmBusyState.start[id]) window.__gmSetBusy("start", id, true); + }); + Object.keys(window.__gmBusyState.verify || {}).forEach(function (id) { + if (window.__gmBusyState.verify[id]) window.__gmSetBusy("verify", id, true); + }); + } + function styleSolid(btn) { btn.style.cssText = BASE_STYLE + "background:" + SOLID_BG + "; color:#fff; border:none; box-shadow:0 1px 2px rgba(0,0,0,0.05);"; - btn.addEventListener("mouseenter", function () { btn.style.background = SOLID_HOVER; }); + btn.addEventListener("mouseenter", function () { + if (btn.dataset.gmBusy === "true") return; + btn.style.background = SOLID_HOVER; + }); btn.addEventListener("mouseleave", function () { btn.style.background = SOLID_BG; }); } function styleOutline(btn) { var colors = outlineColors(); btn.style.cssText = BASE_STYLE + "background:transparent; color:" + colors.text + "; border:1px solid " + OUTLINE_BORDER + ";"; - btn.addEventListener("mouseenter", function () { btn.style.background = colors.hoverBg; }); + btn.addEventListener("mouseenter", function () { + if (btn.dataset.gmBusy === "true") return; + btn.style.background = colors.hoverBg; + }); btn.addEventListener("mouseleave", function () { btn.style.background = "transparent"; }); } @@ -386,9 +490,12 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { function createStartButton(id, label) { var btn = document.createElement("button"); + btn.setAttribute("data-gm-start", id); btn.innerHTML = ICON_DOWNLOAD + '' + (label || 'Start Exercise') + ''; styleSolid(btn); btn.addEventListener("click", function () { + if (btn.dataset.gmBusy === "true") return; + window.__gmSetBusy("start", id, true); window.wcvBridge.send("wcv-start-exercise", { exerciseId: id }); }); return btn; @@ -396,9 +503,12 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { function createVerifyButton(id) { var btn = document.createElement("button"); + btn.setAttribute("data-gm-verify", id); btn.innerHTML = ICON_CHECK + 'Verify Solution'; styleOutline(btn); btn.addEventListener("click", function () { + if (btn.dataset.gmBusy === "true") return; + window.__gmSetBusy("verify", id, true); window.wcvBridge.send("wcv-verify-exercise", { exerciseId: id }); }); return btn; @@ -502,6 +612,7 @@ function injectExerciseButtons(mainWindow: BrowserWindow) { }); injectHandsOnButtons(); + applyBusyState(); } var observedCollapses = window.__gmObservedCollapses || (window.__gmObservedCollapses = new WeakSet()); @@ -661,8 +772,10 @@ export async function scrapeLessonBrief( export function setupWebContentsViewIpc(mainWindow: BrowserWindow) { registerThemeBackgroundTarget((color) => { wcv?.setBackgroundColor(color); + if (pageDimmed) applyPageDim(); }); onAiHintsPageStateChange(pushAiHintsState); + onExercisePageBusy(setPageBusy); ipcMainOn( "wcv-size", ({ @@ -735,6 +848,11 @@ export function setupWebContentsViewIpc(mainWindow: BrowserWindow) { view.webContents.loadURL(url); }); + ipcMainOn("wcv-set-dimmed", ({ dimmed }: { dimmed: boolean }) => { + pageDimmed = dimmed; + applyPageDim(); + }); + // Temporarily hide the wcv, whenever we need to display a full screen modal. ipcMainOn("wcv-hide", () => { isHidden = true; diff --git a/src/electron/main.ts b/src/electron/main.ts index f3da26d..79f77f0 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -32,11 +32,16 @@ app.on("ready", () => { mainWindow = new BrowserWindow({ minWidth: 1024, minHeight: 680, + show: false, backgroundColor: THEME_BACKGROUND[resolvedTheme], webPreferences: { preload: getPreloadPath(), }, }); + mainWindow.once("ready-to-show", () => { + mainWindow?.maximize(); + mainWindow?.show(); + }); setupTheme(mainWindow); setupTerminalIpc(mainWindow); setupGitmasteryIpc(mainWindow); diff --git a/src/electron/preload.cts b/src/electron/preload.cts index 2888b55..c66b8e5 100644 --- a/src/electron/preload.cts +++ b/src/electron/preload.cts @@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld("electron", { ipcSend("wcv-size", { x, y, width, height }), hide: () => ipcSend("wcv-hide", null), show: () => ipcSend("wcv-show", null), + setEmbeddedDimmed: (dimmed: boolean) => ipcSend("wcv-set-dimmed", { dimmed }), onWcvLoading: (callback: (loading: boolean) => void) => ipcOn("wcv-loading", ({ loading }) => callback(loading)), onWcvUrlChanged: (callback: (url: string) => void) => @@ -31,8 +32,9 @@ contextBridge.exposeInMainWorld("electron", { setExerciseRoot: (directory: string) => ipcInvoke("set-exercise-root", { directory }), clearExerciseRoot: () => ipcInvoke("clear-exercise-root", null), - checkStartPrereqs: () => ipcInvoke("check-start-prereqs", null), - markStartIntroSeen: () => ipcInvoke("mark-start-intro-seen", null), + checkStartPrereqs: (options?: { skipIntro?: boolean }) => + ipcInvoke("check-start-prereqs", options ?? {}), + hideStartIntro: () => ipcInvoke("hide-start-intro", null), // GitMastery getDownloadedExercises: () => ipcInvoke("get-downloaded-exercises", null), @@ -47,8 +49,14 @@ contextBridge.exposeInMainWorld("electron", { ipcOn("gitmastery-task-data", (payload) => callback(payload.originalCommand, payload.data), ), - startExercise: (exerciseIdentifier: string) => - ipcInvoke("gitmastery-start-exercise", { exerciseIdentifier }), + startExercise: ( + exerciseIdentifier: string, + options?: { skipIntro?: boolean }, + ) => + ipcInvoke("gitmastery-start-exercise", { + exerciseIdentifier, + skipIntro: options?.skipIntro, + }), onStartExerciseStarted: (callback: (payload: StartExerciseStarted) => void) => ipcOn("start-exercise-started", callback), onStartExerciseResult: (callback: (result: StartExerciseResult) => void) => diff --git a/src/electron/startPrereqs.ts b/src/electron/startPrereqs.ts index 8e22538..fcc31b7 100644 --- a/src/electron/startPrereqs.ts +++ b/src/electron/startPrereqs.ts @@ -29,7 +29,7 @@ export function hasValidExerciseRoot(): boolean { return Boolean(root && isExerciseRoot(root)); } -/** The first Start step that is still outstanding, ignoring the one-time intro. */ +/** The first Start step that is still outstanding, ignoring the introduction. */ export function getBlockingPrereq(): "tools" | "folder" | null { const tools = getToolsStatus(); if (!tools.cli || tools.gitBash === false) return "tools"; @@ -39,10 +39,13 @@ export function getBlockingPrereq(): "tools" | "folder" | null { /** * Intro, then tools, then the exercises folder. Null means Start can run. - * The intro is skipped by Verify; use `getBlockingPrereq` there. + * The intro is skipped by Verify; use `getBlockingPrereq` there. A Start that + * already showed the intro this attempt passes `skipIntro`. */ -export function getStartPrereqStep(): FirstRunStep | null { - if (!getConfig().startIntroSeen) return "intro"; +export function getStartPrereqStep(options?: { + skipIntro?: boolean; +}): FirstRunStep | null { + if (!options?.skipIntro && !getConfig().hideStartIntro) return "intro"; return getBlockingPrereq(); } @@ -103,13 +106,13 @@ export function resolveExerciseRootPick( } export function setupStartPrereqIpc() { - ipcMainHandle("check-start-prereqs", async () => ({ - step: getStartPrereqStep(), + ipcMainHandle("check-start-prereqs", async (payload) => ({ + step: getStartPrereqStep({ skipIntro: payload?.skipIntro }), tools: getToolsStatus(), })); - ipcMainHandle("mark-start-intro-seen", async () => { - saveConfig({ startIntroSeen: true }); + ipcMainHandle("hide-start-intro", async () => { + saveConfig({ hideStartIntro: true }); return true; }); } diff --git a/src/electron/storage.ts b/src/electron/storage.ts index 46fea04..6e59566 100644 --- a/src/electron/storage.ts +++ b/src/electron/storage.ts @@ -17,8 +17,8 @@ interface Config { * This is the folder itself (the one containing `.gitmastery.json`), not its parent. */ exercisesRoot?: string; - /** The learner has seen the first-Start introduction. */ - startIntroSeen?: boolean; + /** When true, Start skips the introduction modal. */ + hideStartIntro?: boolean; /** Desktop + site colour preference. System follows the OS. */ theme?: SitePageTheme; /** Parent folder chosen by older builds. Migrated to `exercisesRoot` on read. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c28fb2d..799fe6e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -11,6 +11,11 @@ import { useWebContentsView, } from "./contexts/WebContentsViewContext"; import { useAiHintsSession } from "./hooks/useAiHintsSession"; +import { useWalkthrough } from "./hooks/useWalkthrough"; +import { + WalkthroughCard, + WalkthroughDim, +} from "./components/Walkthrough/Walkthrough"; import { cx } from "./utils/cx"; const MIN_MAIN = 320; @@ -26,12 +31,16 @@ function App() { // Null until the learner drags the divider; until then the CSS default // keeps the split proportional to the window height. const [hintsHeight, setHintsHeight] = useState(null); - const [lessonsPanelOpened, setLessonsPanelOpened] = useState(false); + const [lessonsPanelOpened, setLessonsPanelOpened] = useState(true); const asideRef = useRef(null); const hints = useAiHintsSession(); const showHints = hints.session !== null && hints.open; + const walkthrough = useWalkthrough(); + const focusLessons = walkthrough.step === "lessons"; + const focusTerminal = walkthrough.step === "terminal"; + const onLessons = getSiteSection(currentUrl) === "lessons"; const showLessonsPanel = onLessons && lessonsPanelOpened; @@ -64,13 +73,15 @@ function App() {
-
+
+
{showLessonsPanel && ( -
)} + {/* The work column: AI hints stacked over the terminal, so a hint @@ -103,6 +115,7 @@ function App() { )} > +
)} -
+
+ {focusTerminal && ( +
+ )}
+ + {walkthrough.step !== null && walkthrough.index !== null && ( + + )}
diff --git a/src/ui/components/Header/Header.tsx b/src/ui/components/Header/Header.tsx index bdacba5..e3828c0 100644 --- a/src/ui/components/Header/Header.tsx +++ b/src/ui/components/Header/Header.tsx @@ -1,11 +1,18 @@ +import { IconHelp } from "@tabler/icons-react"; +import { IconButton } from "../ui/IconButton"; import { SettingsMenu } from "./SettingsMenu"; import { SiteNav } from "./SiteNav"; -export const Header = () => { +export const Header = ({ onHelp }: { onHelp: () => void }) => { return (
- +
+ + + + +
); }; diff --git a/src/ui/components/Header/ToursMenu.tsx b/src/ui/components/Header/ToursMenu.tsx index 56c7e70..5403f3b 100644 --- a/src/ui/components/Header/ToursMenu.tsx +++ b/src/ui/components/Header/ToursMenu.tsx @@ -3,6 +3,7 @@ import { IconChevronDown, IconChevronLeft, IconChevronRight, + IconHome, } from "@tabler/icons-react"; import type { Exercise } from "../../../types/Exercise"; import type { Lesson, Tour, TourData } from "../../../types/Tour"; @@ -12,6 +13,7 @@ import { buildLessonUrl, buildTourHomeUrl, isLessonUrlActive, + isTourHomeUrlActive, isTourUrlActive, useWebContentsView, } from "../../contexts/WebContentsViewContext"; @@ -149,10 +151,7 @@ const TourItem = ({ {opened && (
+ {Object.values(tour.lessons).map((lesson) => ( = { }; /** - * Walks a learner through the first Start: a short introduction, then the CLI - * (and Git Bash on Windows), then the exercises folder. Closing it does + * Walks a learner through Start: a short introduction, then the CLI + * (and Git Bash on Windows), then the exercises folder. The introduction + * repeats until they check "Don't show this again". Closing it does * nothing else; the next Start resumes at whichever step is still missing. */ export const FirstStartModal = ({ @@ -30,7 +32,7 @@ export const FirstStartModal = ({ onResolved: (step: FirstRunStep | null) => void; }) => { const advance = async () => { - const result = await window.electron.checkStartPrereqs(); + const result = await window.electron.checkStartPrereqs({ skipIntro: true }); onResolved(result.step); return result; }; @@ -39,8 +41,8 @@ export const FirstStartModal = ({ {step === "intro" && ( { - await window.electron.markStartIntroSeen(); + onContinue={async (hideAgain) => { + if (hideAgain) await window.electron.hideStartIntro(); await advance(); }} /> @@ -58,8 +60,13 @@ export const FirstStartModal = ({ ); }; -const IntroStep = ({ onContinue }: { onContinue: () => Promise }) => { +const IntroStep = ({ + onContinue, +}: { + onContinue: (hideAgain: boolean) => Promise; +}) => { const [busy, setBusy] = useState(false); + const hideAgainRef = useRef(null); return (
@@ -68,12 +75,15 @@ const IntroStep = ({ onContinue }: { onContinue: () => Promise }) => { on the right, then click Verify to check your answer.

Hands-on practicals work the same way, without a check at the end.

+
+ )} + +
+
+ ); +}; diff --git a/src/ui/hooks/useWalkthrough.ts b/src/ui/hooks/useWalkthrough.ts new file mode 100644 index 0000000..4f99f83 --- /dev/null +++ b/src/ui/hooks/useWalkthrough.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLocalStorage } from "./useLocalStorage"; + +export type WalkthroughStep = "lessons" | "terminal"; + +export const WALKTHROUGH_STEPS: WalkthroughStep[] = ["lessons", "terminal"]; + +/** + * The app walkthrough: runs once on first launch, and again whenever Help is + * clicked. Null `step` means the app behaves as normal. + */ +export function useWalkthrough() { + const [seen, setSeen] = useLocalStorage({ + key: "gm-walkthrough-seen", + defaultValue: false, + }); + const [index, setIndex] = useState(() => (seen ? null : 0)); + const step = index === null ? null : WALKTHROUGH_STEPS[index]; + + const start = useCallback(() => setIndex(0), []); + + const finish = useCallback(() => { + setIndex(null); + setSeen(true); + }, [setSeen]); + + const next = useCallback(() => { + if (index === null) return; + if (index >= WALKTHROUGH_STEPS.length - 1) finish(); + else setIndex(index + 1); + }, [index, finish]); + + const back = useCallback(() => { + if (index === null) return; + setIndex(Math.max(0, index - 1)); + }, [index]); + + // The lesson page is a native view, so it is dimmed from inside the page + // rather than by a DOM overlay. + useEffect(() => { + window.electron.setEmbeddedDimmed(step === "terminal"); + }, [step]); + + return { step, index, start, next, back }; +} diff --git a/src/ui/index.css b/src/ui/index.css index 5bebcc3..49eda89 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -36,6 +36,7 @@ --color-faint: var(--gm-faint); --color-border: var(--gm-border); --color-overlay: var(--gm-overlay); + --color-dim: var(--gm-dim); --color-accent: var(--gm-accent); --color-accent-soft: var(--gm-accent-soft); --color-accent-soft-hover: var(--gm-accent-soft-hover); @@ -88,6 +89,8 @@ --gm-faint: #a3a3a3; --gm-border: #e5e5e5; --gm-overlay: rgb(23 23 23 / 0.25); + /* Walkthrough dimming. Mirrored in src/electron/ipc/webContentsView.ts for the lesson page. */ + --gm-dim: rgb(23 23 23 / 0.45); --gm-accent: #236e3d; --gm-accent-soft: #f2faf5; --gm-accent-soft-hover: #e1f4e8; @@ -129,6 +132,7 @@ html[data-theme="dark"] { --gm-faint: #adb5bd; --gm-border: #495057; --gm-overlay: rgb(0 0 0 / 0.5); + --gm-dim: rgb(0 0 0 / 0.6); --gm-accent: #75b798; --gm-accent-soft: #1f3932; --gm-accent-soft-hover: #264a40; diff --git a/src/ui/providers/ActivityProvider.tsx b/src/ui/providers/ActivityProvider.tsx index 20aec2b..b076f58 100644 --- a/src/ui/providers/ActivityProvider.tsx +++ b/src/ui/providers/ActivityProvider.tsx @@ -82,9 +82,9 @@ export function ActivityProvider({ children }: { children: ReactNode }) { /** * The main process reports every start, whether it came from the app or from - * the button injected into the embedded lesson page. Success is visible as a - * `cd` in the terminal, so the loading toast is dismissed rather than - * restated. Failures that are not CLI output still need a one-line toast. + * the button injected into the embedded lesson page. Success is a `cd` in the + * terminal plus a toast; failures that are not CLI output still need a + * one-line toast. */ const onStartExerciseResult = (result: StartExerciseResult) => { const id = startToastId(result.exerciseIdentifier); @@ -99,8 +99,10 @@ export function ActivityProvider({ children }: { children: ReactNode }) { } setGate(null); if (result.ok) { - openActionToasts.current.delete(id); - hideToast(id); + settleToast(id, { + title: "Started exercise", + tone: "success", + }); return; } @@ -225,7 +227,9 @@ export function ActivityProvider({ children }: { children: ReactNode }) { if (step === null) { const exerciseIdentifier = gate.exerciseIdentifier; setGate(null); - void window.electron.startExercise(exerciseIdentifier); + void window.electron.startExercise(exerciseIdentifier, { + skipIntro: true, + }); return; } setGate({ exerciseIdentifier: gate.exerciseIdentifier, step }); diff --git a/types.d.ts b/types.d.ts index b2c8b17..3001e42 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,6 +15,8 @@ interface Window { navigate: (url: string) => void; hide: () => void; show: () => void; + /** Dims the lesson page in place, for the walkthrough. */ + setEmbeddedDimmed: (dimmed: boolean) => void; onWcvLoading: (callback: (loading: boolean) => void) => () => void; onWcvUrlChanged: (callback: (url: string) => void) => () => void; getSitePrefs: () => Promise; @@ -33,11 +35,11 @@ interface Window { directory: string, ) => Promise<{ ok: true; root: string } | { ok: false; error: string }>; clearExerciseRoot: () => Promise; - checkStartPrereqs: () => Promise<{ + checkStartPrereqs: (options?: { skipIntro?: boolean }) => Promise<{ step: FirstRunStep | null; tools: ToolsStatus; }>; - markStartIntroSeen: () => Promise; + hideStartIntro: () => Promise; // for retrieving config settings of the backend (electron app) // just an array of folder names @@ -50,7 +52,10 @@ interface Window { // TODO: decide whether this command should return when (1) task starts or (2) task completes startGitMasteryTask: (command: string) => Promise; - startExercise: (exerciseIdentifier: string) => Promise; + startExercise: ( + exerciseIdentifier: string, + options?: { skipIntro?: boolean }, + ) => Promise; onStartExerciseStarted: ( callback: (payload: StartExerciseStarted) => void, @@ -100,6 +105,7 @@ type IpcHandlerChannelMapping = { "wcv-show": null; "wcv-size": { x: number; y: number; width: number; height: number }; "wcv-hide": null; + "wcv-set-dimmed": { dimmed: boolean }; "wcv-loading": { loading: boolean }; "wcv-url-changed": { url: string }; "set-app-theme": { @@ -142,10 +148,10 @@ type IpcInvokeChannelMapping = { >; "clear-exercise-root": IIpcInvoke; "check-start-prereqs": IIpcInvoke< - null, + { skipIntro?: boolean }, { step: FirstRunStep | null; tools: ToolsStatus } >; - "mark-start-intro-seen": IIpcInvoke; + "hide-start-intro": IIpcInvoke; "wcv-get-site-prefs": IIpcInvoke; "wcv-set-site-prefs": IIpcInvoke< @@ -157,7 +163,7 @@ type IpcInvokeChannelMapping = { "get-downloaded-exercises": IIpcInvoke; "gitmastery-start-task": IIpcInvoke<{ command: string }, boolean>; "gitmastery-start-exercise": IIpcInvoke< - { exerciseIdentifier: string }, + { exerciseIdentifier: string; skipIntro?: boolean }, StartExerciseResult >;