From 43f91a11a5089d75f2e6eccb9889188d496e2272 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 26 Sep 2026 17:00:47 +0800 Subject: [PATCH 1/3] fix: name the Start intro after the exercise The missing-CLI dialog is now a title plus a setup link, so learners go to the lesson instead of a checklist. Co-authored-by: Cursor --- docs/architecture/exercises-root.md | 7 +- src/ui/components/Setup/FirstStartModal.tsx | 102 +++++--------------- src/ui/providers/ActivityProvider.tsx | 10 +- src/ui/utils/format.ts | 7 ++ 4 files changed, 44 insertions(+), 82 deletions(-) diff --git a/docs/architecture/exercises-root.md b/docs/architecture/exercises-root.md index ecbb62b..6911706 100644 --- a/docs/architecture/exercises-root.md +++ b/docs/architecture/exercises-root.md @@ -21,9 +21,10 @@ button in the app and the one in the lesson page behave the same. 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 - launch and a retry cannot see a change. On macOS and Linux the step can be - checked again immediately. + CLI. The step is a short dialog: title says the CLI was not found, and + "here" opens T1L2 (`/lessons/gitPrep/#installing-the-git-mastery-app`) then + closes so the lesson is visible. The next Start re-checks. On Windows, PATH + is read at launch, so a newly installed CLI is invisible until restart. 3. **Folder.** The learner picks the exercises folder. It is accepted when it contains `.gitmastery.json`. If they pick the parent and exactly one subfolder is a root, that subfolder is used. The copy says this step does diff --git a/src/ui/components/Setup/FirstStartModal.tsx b/src/ui/components/Setup/FirstStartModal.tsx index 96946d7..60148cd 100644 --- a/src/ui/components/Setup/FirstStartModal.tsx +++ b/src/ui/components/Setup/FirstStartModal.tsx @@ -1,17 +1,15 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "../ui/Button"; import { Checkbox } from "../ui/Checkbox"; import { Modal } from "../ui/Modal"; -import { Spinner } from "../ui/Spinner"; import { GIT_PREP_SETUP_URL, useWebContentsView, } from "../../contexts/WebContentsViewContext"; import { ExerciseFolderPanel } from "./ExerciseFolderPanel"; -const TITLES: Record = { - intro: "Before you start", - tools: "Install the tools", +const TITLES: Record, string> = { + tools: "Git-Mastery CLI was not found.", folder: "Choose your exercises folder", }; @@ -23,10 +21,12 @@ const TITLES: Record = { */ export const FirstStartModal = ({ step, + activityName, onClose, onResolved, }: { step: FirstRunStep; + activityName: string; onClose: () => void; /** Called with the next outstanding step, or null when Start can run. */ onResolved: (step: FirstRunStep | null) => void; @@ -37,8 +37,10 @@ export const FirstStartModal = ({ return result; }; + const title = step === "intro" ? `Starting ${activityName}` : TITLES[step]; + return ( - + {step === "intro" && ( { @@ -47,9 +49,7 @@ export const FirstStartModal = ({ }} /> )} - {step === "tools" && ( - - )} + {step === "tools" && } {step === "folder" && ( void advance()} @@ -93,77 +93,23 @@ const IntroStep = ({ ); }; -const ToolsStep = ({ - onRecheck, - onOpenGuide, -}: { - onRecheck: () => Promise<{ step: FirstRunStep | null; tools: ToolsStatus }>; - onOpenGuide: () => void; -}) => { +const ToolsStep = ({ onOpenGuide }: { onOpenGuide: () => void }) => { const { navigate } = useWebContentsView(); - const [tools, setTools] = useState(null); - const [checking, setChecking] = useState(false); - - useEffect(() => { - window.electron - .checkStartPrereqs({ skipIntro: true }) - .then((result) => setTools(result.tools)) - .catch(() => setTools(null)); - }, []); - - const showRecheck = tools !== null && tools.gitBash === null; return ( -
- {tools === null ? ( - - ) : ( -
    - - {tools.gitBash !== null && ( - - )} -
- )} - - {tools !== null && ( -

- {showRecheck - ? "Install anything missing, then check again." - : "Install anything missing, then quit and reopen the app so it can see the new tools."} -

- )} - -
- {showRecheck && ( - - )} - -
-
+

+ Download and set up gitmastery{" "} + + . +

); }; - -const ToolLine = ({ label, ok }: { label: string; ok: boolean }) => ( -
  • - {ok ? `${label} is installed.` : `${label} was not found.`} -
  • -); diff --git a/src/ui/providers/ActivityProvider.tsx b/src/ui/providers/ActivityProvider.tsx index b076f58..1a59ddd 100644 --- a/src/ui/providers/ActivityProvider.tsx +++ b/src/ui/providers/ActivityProvider.tsx @@ -17,8 +17,9 @@ import { useElectronStream } from "../hooks/useElectronStream"; import { useLocalExercises } from "../hooks/query/useLocalExercises"; import { useToast, type ToastOptions } from "../contexts/ToastContext"; import { ActivityContext } from "../contexts/ActivityContext"; -import { isHandsOnIdentifier } from "../utils/format"; import { FirstStartModal } from "../components/Setup/FirstStartModal"; +import { useExercises } from "../hooks/query/useExercises"; +import { formatActivityName, isHandsOnIdentifier } from "../utils/format"; const isVerifyCommand = (cmd: string) => cmd.startsWith("verify"); @@ -32,6 +33,7 @@ export function ActivityProvider({ children }: { children: ReactNode }) { const { showToast, updateToast, hideToast } = useToast(); const { downloadedExerciseData, patchExerciseStatus } = useLocalExercises(); + const { query: exercisesQuery } = useExercises(); /** Loading toasts currently on screen, so settle can update them in place. */ const openActionToasts = useRef>(new Set()); @@ -245,6 +247,12 @@ export function ActivityProvider({ children }: { children: ReactNode }) { {gate && ( exercise.identifier === gate.exerciseIdentifier, + ), + )} onClose={() => setGate(null)} onResolved={onGateResolved} /> diff --git a/src/ui/utils/format.ts b/src/ui/utils/format.ts index 1cb9005..c660308 100644 --- a/src/ui/utils/format.ts +++ b/src/ui/utils/format.ts @@ -27,6 +27,13 @@ export const formatExerciseTitle = (exercise: Exercise) => { return formatExerciseIdentifier(exercise.identifier); }; +/** Display name for an exercise or hands-on, from its identifier and optional catalog entry. */ +export const formatActivityName = (identifier: string, exercise?: Exercise) => { + if (isHandsOnIdentifier(identifier)) return formatHandsOnTitle(identifier); + if (exercise) return formatExerciseTitle(exercise); + return formatExerciseIdentifier(identifier); +}; + export const getExerciseLessonName = (exercise: Exercise) => { return exercise.lesson?.lesson_name ?? exercise.detour?.lesson?.lesson_name; }; From d3df26eed1efaecc62470c594845f99db928882a Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 26 Sep 2026 17:34:44 +0800 Subject: [PATCH 2/3] fix: cd into the hands-on sandbox repo Hands-on downloads have no manifest, so Start now treats a single subdirectory as the working folder, the same role as an exercise repo_name. Co-authored-by: Cursor --- docs/architecture/ai-context-providers.md | 6 +++--- .../exercise-directory-resolution.md | 14 +++++++++---- src/electron/exerciseManifest.ts | 20 +++++++++++++++---- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/architecture/ai-context-providers.md b/docs/architecture/ai-context-providers.md index 870a977..c51238c 100644 --- a/docs/architecture/ai-context-providers.md +++ b/docs/architecture/ai-context-providers.md @@ -74,9 +74,9 @@ so by the time a hint is requested it can point anywhere. See `exercise-director for the same problem in `verify`. `ExerciseLocation` carries the exercises root, the exercise root and the working folder (where Start -`cd`s). For hands-on practicals the working folder is the exercise root, but the repository usually -sits one level down (`hp-init-repo/things`). The git provider therefore searches rather than -assuming (§5). +`cd`s). For hands-on practicals Start `cd`s into the single subdirectory when there is one +(`hp-init-repo/things`); otherwise it stays at the exercise root. The git provider still searches +rather than assuming (§5), because some practicals create the repository later. ## 4. What is never sent diff --git a/docs/architecture/exercise-directory-resolution.md b/docs/architecture/exercise-directory-resolution.md index 3e04d50..f36cf08 100644 --- a/docs/architecture/exercise-directory-resolution.md +++ b/docs/architecture/exercise-directory-resolution.md @@ -154,7 +154,8 @@ must be directories, not merely exist: | `exerciseRoot` does not exist | `{ state: "not-downloaded" }` | | `exerciseRoot` exists but is not a directory | `{ state: "corrupt", exerciseRoot }` | | Identifier starts with `hp-` and the directory is empty | `{ state: "incomplete", exerciseRoot }` | -| Identifier starts with `hp-` and the directory has files | `{ state: "ready", cwd: exerciseRoot }` | +| Identifier starts with `hp-` and one non-dot subdirectory | `{ state: "ready", cwd: that subdir }` | +| Identifier starts with `hp-` otherwise | `{ state: "ready", cwd: exerciseRoot }` | | Manifest missing, unparseable, or missing `exercise_repo` | `{ state: "corrupt", exerciseRoot }` | | `repo_type === "ignore"` | `{ state: "ready", cwd: exerciseRoot }` | | `repo_name` is not a single path segment | `{ state: "corrupt", exerciseRoot }` | @@ -164,9 +165,14 @@ must be directories, not merely exist: Hands-on practices (`gitmastery download hp-`) are set up by a Python `download` function rather than from a manifest (`_download_hands_on` in `app/commands/download.py`), so no -`.gitmastery-exercise.json` is ever written for them and the learner works at the exercise root. -Without the prefix check they would all resolve as `corrupt`. An empty leftover from a failed -hands-on download is `incomplete` so Start will not silently `cd` into it and skip retry. +`.gitmastery-exercise.json` is ever written for them. Without the prefix check they would all +resolve as `corrupt`. An empty leftover from a failed hands-on download is `incomplete` so +Start will not silently `cd` into it and skip retry. + +The sandbox repo is almost always the single subdirectory (`hp-init-repo/things`, +`hp-view-commits/things`, a clone). That is the same role as an exercise's `repo_name`, and +the lessons tell the learner to `cd hp-…/things`. Start therefore `cd`s into that subdirectory +when there is exactly one. Zero or several non-dot subdirectories stay at the exercise root. `repo_name` is read off disk and joined onto the exercise root, so it is rejected unless it is a single path segment — the resolved cwd is `cd`-ed into by Start and, once the terminal is diff --git a/src/electron/exerciseManifest.ts b/src/electron/exerciseManifest.ts index 2e5571e..2454580 100644 --- a/src/electron/exerciseManifest.ts +++ b/src/electron/exerciseManifest.ts @@ -27,7 +27,8 @@ export const EXERCISE_MANIFEST_NAME = ".gitmastery-exercise.json"; /** * Hands-on practices are set up by a download script rather than a manifest, so - * there is nothing to read and the learner works at the exercise root. + * there is nothing to read. The learner works in the single subdirectory when + * there is one (the sandbox repo), otherwise at the exercise root. */ export const HANDS_ON_PREFIX = "hp-"; @@ -107,11 +108,22 @@ export function resolveExerciseCwd(exerciseRoot: string): ExerciseCwdResult { if (!isDirectory(exerciseRoot)) return { state: "corrupt", exerciseRoot }; if (path.basename(exerciseRoot).startsWith(HANDS_ON_PREFIX)) { - // Hands-on practices have no manifest, so an empty leftover from a failed - // download is indistinguishable from a successful one except by contents. - if (fs.readdirSync(exerciseRoot).length === 0) { + // Hands-on downloads have no manifest. The sandbox repo is almost always + // the single subdirectory (`things`, a clone, …), the same role as an + // exercise's `repo_name`. Lessons tell the learner to `cd hp-…/things`. + const entries = fs.readdirSync(exerciseRoot, { withFileTypes: true }); + if (entries.length === 0) { return { state: "incomplete", exerciseRoot }; } + const subdirs = entries.filter( + (entry) => entry.isDirectory() && !entry.name.startsWith("."), + ); + if (subdirs.length === 1 && isPathSegment(subdirs[0].name)) { + return { + state: "ready", + cwd: path.join(exerciseRoot, subdirs[0].name), + }; + } return { state: "ready", cwd: exerciseRoot }; } From a755fcb6132d3a24e0d62125b6efe66ba114d867 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 26 Sep 2026 17:34:45 +0800 Subject: [PATCH 3/3] fix: use two shared AI hint suggestions Exercises and hands-on now offer the same two openers. Co-authored-by: Cursor --- src/ui/components/AiHints/AiHintsPanel.tsx | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/ui/components/AiHints/AiHintsPanel.tsx b/src/ui/components/AiHints/AiHintsPanel.tsx index 7f53def..d616195 100644 --- a/src/ui/components/AiHints/AiHintsPanel.tsx +++ b/src/ui/components/AiHints/AiHintsPanel.tsx @@ -30,21 +30,11 @@ const KIND_LABEL: Record = { "hands-on": "Hands-on", }; -/** Openers that model the kind of question that earns a useful hint. */ -const SUGGESTIONS: Record = { - exercise: [ - "What is this exercise asking me to do?", - "I'm stuck. What should I look at next?", - "Does my repository look right so far?", - "Explain the Git concept behind this exercise", - ], - "hands-on": [ - "What will this hands-on teach me?", - "Which step am I on, based on my repo?", - "My last command didn't work. What went wrong?", - "Explain the current step in simpler terms", - ], -}; +/** Same openers for exercises and hands-on. */ +const SUGGESTIONS = [ + "I'm stuck. What should I do next?", + "Explain the underlying concepts to me", +]; const DISCLAIMER: Record = { exercise: "Hints only, never the full solution. AI can make mistakes.", @@ -181,7 +171,7 @@ const AiHintsConversation = ({ Stuck on this {noun}?
    - {SUGGESTIONS[session.kind].map((suggestion) => ( + {SUGGESTIONS.map((suggestion) => (