handzon-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +97 -3
  3. package/src/components/ai/ChatButton.tsx +51 -3
  4. package/src/components/ai/ChatPanel.tsx +86 -23
  5. package/src/components/ai/CopyStep.tsx +44 -0
  6. package/src/components/ai/OpenInAgent.tsx +55 -0
  7. package/src/components/ai/SelectionAsk.tsx +98 -0
  8. package/src/components/ai/StepHelp.tsx +31 -0
  9. package/src/components/mdx/Checkpoint.tsx +66 -2
  10. package/src/components/mdx/CopyPrompt.astro +10 -0
  11. package/src/components/mdx/CopyPrompt.tsx +56 -0
  12. package/src/components/mdx/HelpMe.astro +10 -0
  13. package/src/components/mdx/HelpMe.tsx +29 -0
  14. package/src/components/mdx/Playground.tsx +61 -9
  15. package/src/components/mdx/Quiz.tsx +18 -0
  16. package/src/index.ts +5 -0
  17. package/src/layouts/TutorialLayout.astro +19 -0
  18. package/src/lib/ai/assist.ts +81 -0
  19. package/src/lib/ai/prompts.ts +126 -0
  20. package/src/lib/ai/stepData.ts +74 -0
  21. package/src/lib/mdx-components.ts +4 -0
  22. package/src/lib/progress/remote.ts +86 -25
  23. package/src/lib/progress/types.ts +23 -0
  24. package/src/lib/progress/useProgress.ts +8 -4
  25. package/src/pages/TutorialStep.astro +12 -1
  26. package/src/server/auth.ts +84 -1
  27. package/src/server/db/schema.ts +53 -0
  28. package/src/server/handlers/helpInbox.ts +45 -0
  29. package/src/server/handlers/mcp.ts +72 -0
  30. package/src/server/handlers/progress.ts +7 -51
  31. package/src/server/handlers/progressEvents.ts +68 -0
  32. package/src/server/mcp/protocol.ts +99 -0
  33. package/src/server/mcp/server.ts +94 -0
  34. package/src/server/mcp/tools.ts +175 -0
  35. package/src/server/mcp/writeTools.ts +407 -0
  36. package/src/server/progress.ts +86 -0
  37. package/src/server/progressBus.ts +51 -0
  38. package/src/server/tokens.ts +80 -0
  39. package/src/server/verify/evaluator.ts +134 -0
  40. package/src/types/ai.ts +6 -0
  41. package/styles/components/assist.css +101 -0
  42. package/styles/components/checkpoint.css +29 -0
  43. package/styles/components.css +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -86,20 +86,114 @@ export function tutorialsLoader(): Loader {
86
86
  };
87
87
  }
88
88
 
89
- /** Glob loader for tutorial step `.mdx`/`.md` files. */
90
- export function stepsLoader() {
91
- return glob({
89
+ /**
90
+ * Glob loader for tutorial step `.mdx`/`.md` files.
91
+ *
92
+ * After the inner glob populates the store we walk every entry once
93
+ * and reject any step whose `verify.id` doesn't match a
94
+ * `<Checkpoint id="…">` in the MDX body. Catching this at load time
95
+ * means an author who renames a checkpoint id but forgets the
96
+ * frontmatter sees a loud build failure instead of silently broken
97
+ * verification at run time.
98
+ */
99
+ export function stepsLoader(): Loader {
100
+ const inner = glob({
92
101
  pattern: "**/[0-9]*-*.{mdx,md}",
93
102
  base: `./${TUTORIALS_REL}`,
94
103
  });
104
+ return {
105
+ name: "handzon-steps",
106
+ load: async (args) => {
107
+ await inner.load(args);
108
+ const checkpointRe = /<Checkpoint\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
109
+ for (const value of args.store.values()) {
110
+ const verify = (value.data as { verify?: { id?: string } } | undefined)?.verify;
111
+ if (!verify?.id) continue;
112
+ const body = value.body ?? "";
113
+ const ids = new Set<string>();
114
+ checkpointRe.lastIndex = 0;
115
+ for (;;) {
116
+ const m = checkpointRe.exec(body);
117
+ if (m === null) break;
118
+ const id = m[1] ?? m[2] ?? m[3];
119
+ if (id) ids.add(id);
120
+ }
121
+ if (!ids.has(verify.id)) {
122
+ throw new Error(
123
+ `[handzon] step ${value.id}: verify.id "${verify.id}" has no matching <Checkpoint id="…"> in the step body. Either add a <Checkpoint id="${verify.id}" …/> or remove the verify block.`,
124
+ );
125
+ }
126
+ }
127
+ },
128
+ };
95
129
  }
96
130
 
131
+ /**
132
+ * Schema for machine-verifiable checkpoint specs (Family D). Authors
133
+ * declare deterministic checks per step; the agent runs them on the
134
+ * learner's machine and POSTs observed values back to the server,
135
+ * which scores against the spec via the evaluator.
136
+ *
137
+ * `kind` is a discriminated union — only the fields valid for each
138
+ * check kind pass validation.
139
+ */
140
+ export const verifyCheckSchema = z.discriminatedUnion("kind", [
141
+ z.object({
142
+ kind: z.literal("file_exists"),
143
+ path: z.string().min(1),
144
+ hint: z.string().optional(),
145
+ }),
146
+ z.object({
147
+ kind: z.literal("file_contains"),
148
+ path: z.string().min(1),
149
+ /** Regex matched against the file body. */
150
+ pattern: z.string().min(1),
151
+ hint: z.string().optional(),
152
+ }),
153
+ z.object({
154
+ kind: z.literal("shell"),
155
+ run: z.string().min(1),
156
+ expect: z
157
+ .object({
158
+ exitCode: z.number().int().optional(),
159
+ stdoutMatches: z.string().optional(),
160
+ })
161
+ .default({}),
162
+ hint: z.string().optional(),
163
+ }),
164
+ z.object({
165
+ kind: z.literal("http"),
166
+ url: z.string().min(1),
167
+ expect: z
168
+ .object({
169
+ status: z.number().int().optional(),
170
+ bodyIncludes: z.string().optional(),
171
+ bodyMatches: z.string().optional(),
172
+ })
173
+ .default({}),
174
+ hint: z.string().optional(),
175
+ }),
176
+ ]);
177
+
178
+ export type VerifyCheck = z.infer<typeof verifyCheckSchema>;
179
+
180
+ export const verifySchema = z.object({
181
+ /** Must match a <Checkpoint id> in the step's MDX. */
182
+ id: z.string().min(1),
183
+ /** Advisory cwd hint passed to the agent (e.g. "$LEARNER_PROJECT"). */
184
+ cwd: z.string().optional(),
185
+ checks: z.array(verifyCheckSchema).min(1),
186
+ });
187
+
188
+ export type VerifySpec = z.infer<typeof verifySchema>;
189
+
97
190
  /** Schema for tutorial step entries. */
98
191
  export const stepsSchema = z.object({
99
192
  title: z.string(),
100
193
  duration: z.string().optional(),
101
194
  summary: z.string().optional(),
102
195
  ai: z.boolean().optional(),
196
+ verify: verifySchema.optional(),
103
197
  });
104
198
 
105
199
  /** Schema for tutorial entries. Pass through Astro's image() helper. */
@@ -1,6 +1,9 @@
1
1
  import { Sparkles } from "lucide-react";
2
- import { useState } from "react";
2
+ import { useEffect, useState } from "react";
3
+ import { ASSIST_EVENT, ASSIST_READY_EVENT, type AssistEventDetail } from "../../lib/ai/assist";
4
+ import type { ChatMessage } from "../../lib/ai/client";
3
5
  import type { AssistantContext } from "../../lib/ai/context";
6
+ import { buildAssistantPrompt } from "../../lib/ai/prompts";
4
7
  import type { AiConfig } from "../../types/ai";
5
8
  import ChatPanel from "./ChatPanel";
6
9
 
@@ -11,6 +14,41 @@ interface Props {
11
14
 
12
15
  export default function ChatButton({ config, context }: Props) {
13
16
  const [open, setOpen] = useState(false);
17
+ const [seed, setSeed] = useState<ChatMessage[] | undefined>(undefined);
18
+ // Bumped on each new assist seed so ChatPanel remounts with the
19
+ // new initialMessages even if the panel is already open from a
20
+ // previous intent or FAB click.
21
+ const [seedToken, setSeedToken] = useState(0);
22
+
23
+ // Tell Family A islands (HelpMe, Checkpoint nudge, …) that the tutor
24
+ // is mounted on this page. Set the dataset flag for islands that
25
+ // hydrate after us; dispatch the event for islands that hydrated
26
+ // before us and are waiting.
27
+ useEffect(() => {
28
+ document.documentElement.dataset.handzonAi = "ready";
29
+ document.documentElement.dataset.handzonAiTools = JSON.stringify(config.tools ?? {});
30
+ document.dispatchEvent(new CustomEvent(ASSIST_READY_EVENT));
31
+ return () => {
32
+ delete document.documentElement.dataset.handzonAi;
33
+ delete document.documentElement.dataset.handzonAiTools;
34
+ };
35
+ }, [config.tools]);
36
+
37
+ // Listen for touchpoint dispatches and open the panel with the
38
+ // matching seedMessages.
39
+ useEffect(() => {
40
+ function onAssist(e: Event) {
41
+ const detail = (e as CustomEvent<AssistEventDetail>).detail;
42
+ if (!detail?.intent) return;
43
+ const { seedMessages } = buildAssistantPrompt(context, detail.intent);
44
+ setSeed(seedMessages);
45
+ setSeedToken((t) => t + 1);
46
+ setOpen(true);
47
+ }
48
+ document.addEventListener(ASSIST_EVENT, onAssist);
49
+ return () => document.removeEventListener(ASSIST_EVENT, onAssist);
50
+ }, [context]);
51
+
14
52
  if (!config.enabled) return null;
15
53
 
16
54
  return (
@@ -18,13 +56,23 @@ export default function ChatButton({ config, context }: Props) {
18
56
  <button
19
57
  type="button"
20
58
  className="chat-fab"
21
- onClick={() => setOpen(true)}
59
+ onClick={() => {
60
+ setSeed(undefined);
61
+ setOpen(true);
62
+ }}
22
63
  aria-label={`Open ${config.name}`}
23
64
  >
24
65
  <Sparkles size={18} aria-hidden="true" />
25
66
  <span>{config.name}</span>
26
67
  </button>
27
- <ChatPanel open={open} onOpenChange={setOpen} config={config} context={context} />
68
+ <ChatPanel
69
+ key={seedToken}
70
+ open={open}
71
+ onOpenChange={setOpen}
72
+ config={config}
73
+ context={context}
74
+ initialMessages={seed}
75
+ />
28
76
  </>
29
77
  );
30
78
  }
@@ -13,19 +13,27 @@ interface Props {
13
13
  onOpenChange: (open: boolean) => void;
14
14
  config: AiConfig;
15
15
  context: AssistantContext;
16
+ /**
17
+ * Seed turns prepended after the assistant greeting. Callers that
18
+ * open the panel with a specific intent (HelpMe, quiz "why is this
19
+ * wrong?", checkpoint nudge, …) pass a single user turn here; the
20
+ * panel auto-streams a response once on open. The user-turn body
21
+ * stays editable in history but isn't re-sent on reopen.
22
+ */
23
+ initialMessages?: ChatMessage[];
16
24
  }
17
25
 
18
- export default function ChatPanel({ open, onOpenChange, config, context }: Props) {
26
+ export default function ChatPanel({ open, onOpenChange, config, context, initialMessages }: Props) {
19
27
  const [messages, setMessages] = useState<ChatMessage[]>(() => {
20
- if (config.greeting) {
21
- return [{ role: "assistant", content: config.greeting }];
22
- }
23
- return [
24
- {
25
- role: "assistant",
26
- content: `Hi, I'm ${config.name}. Ask me anything about "${context.tutorial.title}". I can see what step you're on.`,
27
- },
28
- ];
28
+ const greeting: ChatMessage = config.greeting
29
+ ? { role: "assistant", content: config.greeting }
30
+ : {
31
+ role: "assistant",
32
+ content: `Hi, I'm ${config.name}. Ask me anything about "${context.tutorial.title}". I can see what step you're on.`,
33
+ };
34
+ return initialMessages && initialMessages.length > 0
35
+ ? [greeting, ...initialMessages]
36
+ : [greeting];
29
37
  });
30
38
  const [input, setInput] = useState("");
31
39
  const [streaming, setStreaming] = useState(false);
@@ -54,21 +62,9 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
54
62
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
55
63
  }, [messages, streaming]);
56
64
 
57
- async function send() {
58
- const trimmed = input.trim();
59
- if (!trimmed || streaming) return;
60
-
61
- if (needsKey) {
62
- setByokOpen(true);
63
- return;
64
- }
65
-
66
- const next: ChatMessage[] = [...messages, { role: "user", content: trimmed }];
67
- setMessages(next);
68
- setInput("");
65
+ async function runStream(next: ChatMessage[]) {
69
66
  setStreaming(true);
70
67
  setError(null);
71
-
72
68
  abortRef.current = new AbortController();
73
69
  try {
74
70
  const stream = await streamChat({
@@ -107,6 +103,73 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
107
103
  }
108
104
  }
109
105
 
106
+ async function send() {
107
+ const trimmed = input.trim();
108
+ if (!trimmed || streaming) return;
109
+
110
+ if (needsKey) {
111
+ setByokOpen(true);
112
+ return;
113
+ }
114
+
115
+ const next: ChatMessage[] = [...messages, { role: "user", content: trimmed }];
116
+ setMessages(next);
117
+ setInput("");
118
+ await runStream(next);
119
+ }
120
+
121
+ // Help-bridge: when the panel first opens and there's no seed from
122
+ // a Family A touchpoint, fetch pending help requests posted by the
123
+ // agent via MCP and prepend them as user turns. Same one-shot ref
124
+ // as the seed below so subsequent opens don't re-replay them.
125
+ const inboxRef = useRef(false);
126
+ // biome-ignore lint/correctness/useExhaustiveDependencies: open is the trigger
127
+ useEffect(() => {
128
+ if (!open || inboxRef.current) return;
129
+ if (initialMessages && initialMessages.length > 0) return;
130
+ inboxRef.current = true;
131
+ void (async () => {
132
+ try {
133
+ const res = await fetch("/api/help-inbox");
134
+ if (!res.ok) return;
135
+ const data = (await res.json()) as {
136
+ requests?: Array<{ query: string; tutorialSlug: string; stepSlug: string }>;
137
+ };
138
+ const queued = data.requests ?? [];
139
+ if (queued.length === 0) return;
140
+ const turns: ChatMessage[] = queued.map((r) => ({
141
+ role: "user",
142
+ content: `From my agent on ${r.tutorialSlug}/${r.stepSlug}: ${r.query}`,
143
+ }));
144
+ setMessages((prev) => [...prev, ...turns]);
145
+ if (!needsKey) void runStream([...messages, ...turns]);
146
+ } catch {
147
+ /* network error — silently fall back to a normal session */
148
+ }
149
+ })();
150
+ }, [open]);
151
+
152
+ // When the panel opens with a pre-seeded user turn (HelpMe, quiz
153
+ // "why is this wrong?", checkpoint nudge, …) auto-trigger the
154
+ // stream once. Gated on `open` so closing + reopening doesn't fire
155
+ // it again, and on a one-shot ref so re-renders during streaming
156
+ // don't either.
157
+ const seededRef = useRef(false);
158
+ // biome-ignore lint/correctness/useExhaustiveDependencies: open is the trigger; messages/needsKey are read once.
159
+ useEffect(() => {
160
+ if (!open) {
161
+ seededRef.current = false;
162
+ inboxRef.current = false;
163
+ return;
164
+ }
165
+ if (seededRef.current) return;
166
+ if (needsKey) return;
167
+ const last = messages[messages.length - 1];
168
+ if (!last || last.role !== "user") return;
169
+ seededRef.current = true;
170
+ void runStream(messages);
171
+ }, [open]);
172
+
110
173
  function clear() {
111
174
  setMessages([]);
112
175
  setError(null);
@@ -0,0 +1,44 @@
1
+ import { Check, ClipboardCopy } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { readStepData } from "../../lib/ai/stepData";
4
+
5
+ /**
6
+ * Family B touchpoint: copies the current step (title + tutorial
7
+ * title + raw MDX source) to the clipboard so the learner can
8
+ * paste into any tool. Always available — no aiConfig.enabled gate.
9
+ *
10
+ * Rendered alongside <OpenInAgent /> in the per-step footer row.
11
+ */
12
+ export default function CopyStep() {
13
+ const [copied, setCopied] = useState(false);
14
+
15
+ async function copy() {
16
+ const data = readStepData();
17
+ if (!data) return;
18
+ const body = [
19
+ `# ${data.stepTitle}`,
20
+ ``,
21
+ `From: ${data.tutorialTitle}`,
22
+ ``,
23
+ data.stepSource,
24
+ ].join("\n");
25
+ try {
26
+ await navigator.clipboard.writeText(body);
27
+ setCopied(true);
28
+ window.setTimeout(() => setCopied(false), 1800);
29
+ } catch {
30
+ /* clipboard not available — fail silently */
31
+ }
32
+ }
33
+
34
+ return (
35
+ <button type="button" className="hz-assist-link" onClick={() => void copy()} aria-live="polite">
36
+ {copied ? (
37
+ <Check size={14} aria-hidden="true" />
38
+ ) : (
39
+ <ClipboardCopy size={14} aria-hidden="true" />
40
+ )}
41
+ <span>{copied ? "Copied" : "Copy step"}</span>
42
+ </button>
43
+ );
44
+ }
@@ -0,0 +1,55 @@
1
+ import { useEffect, useState } from "react";
2
+ import { buildAssistantPrompt } from "../../lib/ai/prompts";
3
+ import { contextFromStepData, readStepData, type StepData } from "../../lib/ai/stepData";
4
+ import CopyStep from "./CopyStep";
5
+
6
+ interface Props {
7
+ /** Hide a specific agent from the row. All four shown by default. */
8
+ hide?: Array<"cursor" | "claude" | "chatgpt" | "vscode">;
9
+ }
10
+
11
+ const AGENTS: Array<{
12
+ key: "cursor" | "claude" | "chatgpt" | "vscode";
13
+ label: string;
14
+ }> = [
15
+ { key: "cursor", label: "Cursor" },
16
+ { key: "claude", label: "Claude" },
17
+ { key: "chatgpt", label: "ChatGPT" },
18
+ { key: "vscode", label: "VS Code" },
19
+ ];
20
+
21
+ /**
22
+ * Family B touchpoint: per-step row of "Open in <agent>" links that
23
+ * fire the explainStep prompt at Cursor / Claude / ChatGPT / VS
24
+ * Code via each tool's deep-link scheme. Always renders — no
25
+ * aiConfig.enabled gate — because the affordance is for learners
26
+ * who bring their own agent.
27
+ */
28
+ export default function OpenInAgent({ hide = [] }: Props) {
29
+ const [data, setData] = useState<StepData | null>(null);
30
+ useEffect(() => {
31
+ setData(readStepData());
32
+ }, []);
33
+ if (!data) return null;
34
+
35
+ const { deepLinks } = buildAssistantPrompt(contextFromStepData(data), { kind: "explainStep" });
36
+ const visible = AGENTS.filter((a) => !hide.includes(a.key));
37
+
38
+ return (
39
+ <div className="hz-openin">
40
+ <span className="hz-openin-label">Explain this step in</span>
41
+ {visible.map((agent) => (
42
+ <a
43
+ key={agent.key}
44
+ className="hz-assist-link"
45
+ href={deepLinks[agent.key]}
46
+ target="_blank"
47
+ rel="noopener noreferrer"
48
+ >
49
+ {agent.label}
50
+ </a>
51
+ ))}
52
+ <CopyStep />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,98 @@
1
+ import { Sparkles } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
4
+
5
+ /** Minimum selection length before the affordance appears. Avoids
6
+ * flashing on stray clicks or accidental drag-selects of one word.
7
+ */
8
+ const MIN_CHARS = 8;
9
+
10
+ interface Anchor {
11
+ top: number;
12
+ left: number;
13
+ text: string;
14
+ }
15
+
16
+ /**
17
+ * Listens for text selection inside <article.prose> (the tutorial
18
+ * step body). When a non-trivial selection lands, renders a small
19
+ * floating button anchored just above the selection range that
20
+ * opens the chat with a "selection" intent.
21
+ *
22
+ * Mounted once per page by TutorialStep alongside ChatButton. Hides
23
+ * itself entirely when the in-app tutor isn't enabled.
24
+ */
25
+ export default function SelectionAsk() {
26
+ const enabled = useAiEnabled();
27
+ const [anchor, setAnchor] = useState<Anchor | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!enabled) return;
31
+ function onMouseUp() {
32
+ // Defer one frame so the selection is finalized before we read it.
33
+ window.requestAnimationFrame(() => {
34
+ const sel = window.getSelection();
35
+ if (!sel || sel.isCollapsed) {
36
+ setAnchor(null);
37
+ return;
38
+ }
39
+ const text = sel.toString().trim();
40
+ if (text.length < MIN_CHARS) {
41
+ setAnchor(null);
42
+ return;
43
+ }
44
+ const range = sel.getRangeAt(0);
45
+ // Only show inside <article.prose>; everywhere else (sidebar,
46
+ // chat, header) selections are not tutor-relevant.
47
+ const article = (range.commonAncestorContainer as Node).parentElement?.closest(
48
+ "article.prose",
49
+ );
50
+ if (!article) {
51
+ setAnchor(null);
52
+ return;
53
+ }
54
+ const rect = range.getBoundingClientRect();
55
+ if (rect.width === 0 && rect.height === 0) {
56
+ setAnchor(null);
57
+ return;
58
+ }
59
+ setAnchor({
60
+ top: window.scrollY + rect.top - 36,
61
+ left: window.scrollX + rect.left + rect.width / 2,
62
+ text,
63
+ });
64
+ });
65
+ }
66
+ function onScroll() {
67
+ setAnchor(null);
68
+ }
69
+ document.addEventListener("mouseup", onMouseUp);
70
+ document.addEventListener("selectionchange", () => {
71
+ const sel = window.getSelection();
72
+ if (!sel || sel.isCollapsed) setAnchor(null);
73
+ });
74
+ window.addEventListener("scroll", onScroll, { passive: true });
75
+ return () => {
76
+ document.removeEventListener("mouseup", onMouseUp);
77
+ window.removeEventListener("scroll", onScroll);
78
+ };
79
+ }, [enabled]);
80
+
81
+ if (!enabled || !anchor) return null;
82
+
83
+ return (
84
+ <button
85
+ type="button"
86
+ className="hz-selection-ask"
87
+ style={{ top: anchor.top, left: anchor.left, transform: "translateX(-50%)" }}
88
+ onClick={() => {
89
+ dispatchAssist({ kind: "selection", text: anchor.text });
90
+ setAnchor(null);
91
+ window.getSelection()?.removeAllRanges();
92
+ }}
93
+ >
94
+ <Sparkles size={12} aria-hidden="true" />
95
+ <span>Ask about this</span>
96
+ </button>
97
+ );
98
+ }
@@ -0,0 +1,31 @@
1
+ import { LifeBuoy } from "lucide-react";
2
+ import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
3
+
4
+ interface Props {
5
+ /** Step title used as the "unstuck" topic seed. */
6
+ stepTitle: string;
7
+ }
8
+
9
+ /**
10
+ * Auto-injected step footer rendered by TutorialStep when
11
+ * `aiConfig.autoStepHelp` is true. A learner who scrolls to the end
12
+ * of an unchecked step without making progress hits this affordance
13
+ * before the next-step nav.
14
+ */
15
+ export default function StepHelp({ stepTitle }: Props) {
16
+ const enabled = useAiEnabled();
17
+ if (!enabled) return null;
18
+ return (
19
+ <aside className="hz-step-help" role="note">
20
+ <LifeBuoy size={16} aria-hidden="true" />
21
+ <span className="hz-step-help-text">Stuck on this step?</span>
22
+ <button
23
+ type="button"
24
+ className="hz-helpme"
25
+ onClick={() => dispatchAssist({ kind: "unstuck", topic: stepTitle })}
26
+ >
27
+ Ask the tutor
28
+ </button>
29
+ </aside>
30
+ );
31
+ }
@@ -1,7 +1,15 @@
1
1
  import { Check } from "lucide-react";
2
- import { useEffect, useId, useState } from "react";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
3
4
  import { useProgress } from "../../lib/progress/useProgress";
4
5
 
6
+ /**
7
+ * Time an unchecked checkpoint must be on-screen before we surface
8
+ * the "Stuck?" nudge. Long enough that learners actively working
9
+ * won't see it, short enough that genuinely stuck learners do.
10
+ */
11
+ const STUCK_DELAY_MS = 45_000;
12
+
5
13
  interface Props {
6
14
  label: string;
7
15
  id?: string;
@@ -31,6 +39,38 @@ export default function Checkpoint({ label, id }: Props) {
31
39
  useProgress();
32
40
  const route = useRoute();
33
41
  const done = !!state.checkpoints[checkpointId];
42
+ const aiEnabled = useAiEnabled();
43
+ const [stuck, setStuck] = useState(false);
44
+ const rootRef = useRef<HTMLDivElement>(null);
45
+
46
+ // Show the "Stuck?" nudge after STUCK_DELAY_MS of an unchecked
47
+ // checkpoint being on-screen. Resets the timer if the checkpoint
48
+ // scrolls back off screen. Fires once — once shown, stays shown
49
+ // until the learner ticks the checkpoint.
50
+ useEffect(() => {
51
+ if (done || !aiEnabled || stuck) return;
52
+ const el = rootRef.current;
53
+ if (!el) return;
54
+ let timer: ReturnType<typeof setTimeout> | null = null;
55
+ const observer = new IntersectionObserver(
56
+ (entries) => {
57
+ for (const e of entries) {
58
+ if (e.isIntersecting) {
59
+ if (timer === null) timer = setTimeout(() => setStuck(true), STUCK_DELAY_MS);
60
+ } else if (timer !== null) {
61
+ clearTimeout(timer);
62
+ timer = null;
63
+ }
64
+ }
65
+ },
66
+ { threshold: 0.5 },
67
+ );
68
+ observer.observe(el);
69
+ return () => {
70
+ observer.disconnect();
71
+ if (timer !== null) clearTimeout(timer);
72
+ };
73
+ }, [done, aiEnabled, stuck]);
34
74
 
35
75
  function onToggle() {
36
76
  if (done) {
@@ -42,13 +82,37 @@ export default function Checkpoint({ label, id }: Props) {
42
82
  if (route) markStepComplete(route.tutorial, route.step);
43
83
  }
44
84
 
85
+ // Family D: inline failure feedback from a submit_verification call.
86
+ // SSE pushes the verification row into state.verificationFeedback;
87
+ // we render it under the checkpoint label when present and the
88
+ // checkpoint isn't ticked yet. Cleared on the next pass or when
89
+ // the learner unchecks the checkpoint.
90
+ const feedback = state.verificationFeedback[checkpointId];
91
+ const showFeedback = !done && feedback && !feedback.pass;
92
+
45
93
  return (
46
- <div className={done ? "checkpoint is-done" : "checkpoint"}>
94
+ <div ref={rootRef} className={done ? "checkpoint is-done" : "checkpoint"}>
47
95
  <button type="button" onClick={onToggle} aria-pressed={done}>
48
96
  <span className="checkpoint-box">{done && <Check size={16} />}</span>
49
97
  <span>{label}</span>
50
98
  </button>
51
99
  {done && <span className="checkpoint-msg">Step complete</span>}
100
+ {stuck && !done && (
101
+ <button
102
+ type="button"
103
+ className="checkpoint-nudge"
104
+ onClick={() => dispatchAssist({ kind: "checkpoint", label })}
105
+ >
106
+ Stuck? Ask the tutor →
107
+ </button>
108
+ )}
109
+ {showFeedback && (
110
+ <div className="checkpoint-feedback" role="status">
111
+ <strong>Check failed</strong>
112
+ {feedback.reason && <p>{feedback.reason}</p>}
113
+ {feedback.hint && <p className="checkpoint-feedback-hint">{feedback.hint}</p>}
114
+ </div>
115
+ )}
52
116
  </div>
53
117
  );
54
118
  }
@@ -0,0 +1,10 @@
1
+ ---
2
+ import CopyPromptIsland from "./CopyPrompt.tsx";
3
+
4
+ interface Props {
5
+ template: string;
6
+ label?: string;
7
+ }
8
+ const props = Astro.props as Props;
9
+ ---
10
+ <CopyPromptIsland client:visible {...props} />