handzon-core 0.7.0 → 0.8.1

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +97 -3
  3. package/src/components/Sidebar.astro +5 -2
  4. package/src/components/ai/ChatButton.tsx +51 -3
  5. package/src/components/ai/ChatPanel.tsx +86 -23
  6. package/src/components/ai/CopyStep.tsx +44 -0
  7. package/src/components/ai/OpenInAgent.tsx +55 -0
  8. package/src/components/ai/SelectionAsk.tsx +98 -0
  9. package/src/components/ai/StepHelp.tsx +31 -0
  10. package/src/components/home/Hero.astro +4 -3
  11. package/src/components/mdx/Checkpoint.tsx +66 -2
  12. package/src/components/mdx/CopyPrompt.astro +10 -0
  13. package/src/components/mdx/CopyPrompt.tsx +56 -0
  14. package/src/components/mdx/HelpMe.astro +10 -0
  15. package/src/components/mdx/HelpMe.tsx +29 -0
  16. package/src/components/mdx/Playground.tsx +61 -9
  17. package/src/components/mdx/Quiz.tsx +18 -0
  18. package/src/index.ts +5 -0
  19. package/src/layouts/BaseLayout.astro +6 -0
  20. package/src/layouts/TutorialLayout.astro +37 -9
  21. package/src/lib/ai/assist.ts +81 -0
  22. package/src/lib/ai/prompts.ts +126 -0
  23. package/src/lib/ai/stepData.ts +74 -0
  24. package/src/lib/mdx-components.ts +4 -0
  25. package/src/lib/progress/remote.ts +86 -25
  26. package/src/lib/progress/types.ts +23 -0
  27. package/src/lib/progress/useProgress.ts +8 -4
  28. package/src/pages/Home.astro +7 -1
  29. package/src/pages/TutorialLanding.astro +6 -4
  30. package/src/pages/TutorialStep.astro +13 -1
  31. package/src/server/auth.ts +84 -1
  32. package/src/server/db/schema.ts +53 -0
  33. package/src/server/handlers/helpInbox.ts +45 -0
  34. package/src/server/handlers/mcp.ts +72 -0
  35. package/src/server/handlers/progress.ts +7 -51
  36. package/src/server/handlers/progressEvents.ts +68 -0
  37. package/src/server/mcp/protocol.ts +99 -0
  38. package/src/server/mcp/server.ts +94 -0
  39. package/src/server/mcp/tools.ts +175 -0
  40. package/src/server/mcp/writeTools.ts +407 -0
  41. package/src/server/progress.ts +86 -0
  42. package/src/server/progressBus.ts +51 -0
  43. package/src/server/tokens.ts +80 -0
  44. package/src/server/verify/evaluator.ts +134 -0
  45. package/src/types/ai.ts +6 -0
  46. package/styles/base.css +16 -12
  47. package/styles/components/assist.css +101 -0
  48. package/styles/components/checkpoint.css +29 -0
  49. package/styles/components.css +1 -0
@@ -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} />
@@ -0,0 +1,56 @@
1
+ import { Check, ClipboardCopy } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { readStepData, renderTemplate } from "../../lib/ai/stepData";
4
+
5
+ interface Props {
6
+ /**
7
+ * Templated prompt body. Supports `{{tutorialTitle}}`,
8
+ * `{{tutorialSlug}}`, `{{stepTitle}}`, `{{stepSlug}}`, and
9
+ * `{{stepSource}}` placeholders. Unknown placeholders are left
10
+ * untouched so typos surface.
11
+ */
12
+ template: string;
13
+ /** Override the default button label. */
14
+ label?: string;
15
+ }
16
+
17
+ /**
18
+ * Family B touchpoint: author-placed "Copy prompt" button that
19
+ * always works (no aiConfig required). Substitutes step + tutorial
20
+ * data into the template at click time so it stays in sync if the
21
+ * author edits the step.
22
+ */
23
+ export default function CopyPrompt({ template, label }: Props) {
24
+ const [copied, setCopied] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ async function copy() {
28
+ setError(null);
29
+ const data = readStepData();
30
+ const body = data ? renderTemplate(template, data) : template;
31
+ try {
32
+ await navigator.clipboard.writeText(body);
33
+ setCopied(true);
34
+ window.setTimeout(() => setCopied(false), 1800);
35
+ } catch (e) {
36
+ setError(e instanceof Error ? e.message : String(e));
37
+ }
38
+ }
39
+
40
+ return (
41
+ <button
42
+ type="button"
43
+ className="hz-copy-prompt"
44
+ onClick={() => void copy()}
45
+ aria-live="polite"
46
+ title={error ?? undefined}
47
+ >
48
+ {copied ? (
49
+ <Check size={14} aria-hidden="true" />
50
+ ) : (
51
+ <ClipboardCopy size={14} aria-hidden="true" />
52
+ )}
53
+ <span>{copied ? "Copied" : (label ?? "Copy prompt")}</span>
54
+ </button>
55
+ );
56
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ import HelpMeIsland from "./HelpMe.tsx";
3
+
4
+ interface Props {
5
+ topic?: string;
6
+ label?: string;
7
+ }
8
+ const props = Astro.props as Props;
9
+ ---
10
+ <HelpMeIsland client:visible {...props} />
@@ -0,0 +1,29 @@
1
+ import { LifeBuoy } from "lucide-react";
2
+ import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
3
+
4
+ interface Props {
5
+ /** Free-form topic appended to the seed message ("I'm stuck on …"). */
6
+ topic?: string;
7
+ /** Override the default button label. */
8
+ label?: string;
9
+ }
10
+
11
+ /**
12
+ * Author-placed inline button that opens the in-app tutor pre-seeded
13
+ * with an "unstuck" intent. Renders nothing when `aiConfig.enabled`
14
+ * is false on the host page.
15
+ */
16
+ export default function HelpMe({ topic, label }: Props) {
17
+ const enabled = useAiEnabled();
18
+ if (!enabled) return null;
19
+ return (
20
+ <button
21
+ type="button"
22
+ className="hz-helpme"
23
+ onClick={() => dispatchAssist({ kind: "unstuck", topic })}
24
+ >
25
+ <LifeBuoy size={14} aria-hidden="true" />
26
+ <span>{label ?? (topic ? `Stuck on ${topic}?` : "Stuck? Ask the tutor")}</span>
27
+ </button>
28
+ );
29
+ }
@@ -1,4 +1,14 @@
1
- import { Sandpack, type SandpackPredefinedTemplate } from "@codesandbox/sandpack-react";
1
+ import {
2
+ SandpackCodeEditor,
3
+ SandpackConsole,
4
+ SandpackLayout,
5
+ type SandpackPredefinedTemplate,
6
+ SandpackPreview,
7
+ SandpackProvider,
8
+ useSandpack,
9
+ } from "@codesandbox/sandpack-react";
10
+ import { Sparkles } from "lucide-react";
11
+ import { dispatchAssist, useAiTool } from "../../lib/ai/assist";
2
12
 
3
13
  interface Props {
4
14
  template?: SandpackPredefinedTemplate;
@@ -6,6 +16,42 @@ interface Props {
6
16
  dependencies?: Record<string, string>;
7
17
  height?: number;
8
18
  showConsole?: boolean;
19
+ /**
20
+ * Whether to render the "Ask AI to fix" button in the toolbar.
21
+ * Driven by `aiConfig.tools.suggestPlaygroundEdit` on the host
22
+ * page; the button still hides itself client-side when the tutor
23
+ * isn't enabled.
24
+ */
25
+ askAiToFix?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Toolbar button rendered inside SandpackProvider so useSandpack
30
+ * can read the *current* file contents (post-edit) at click time.
31
+ */
32
+ function AskAiButton() {
33
+ const toolEnabled = useAiTool("suggestPlaygroundEdit");
34
+ const { sandpack } = useSandpack();
35
+ if (!toolEnabled) return null;
36
+
37
+ function onClick() {
38
+ const files: Record<string, string> = {};
39
+ for (const [path, entry] of Object.entries(sandpack.files)) {
40
+ // Hidden files are author scaffolding (e.g. config) the learner
41
+ // didn't touch — exclude them so the prompt focuses on what
42
+ // they're actually editing.
43
+ if (entry.hidden) continue;
44
+ files[path] = entry.code;
45
+ }
46
+ dispatchAssist({ kind: "playgroundFix", files });
47
+ }
48
+
49
+ return (
50
+ <button type="button" className="hz-helpme hz-playground-ask" onClick={onClick}>
51
+ <Sparkles size={14} aria-hidden="true" />
52
+ <span>Ask AI to fix</span>
53
+ </button>
54
+ );
9
55
  }
10
56
 
11
57
  export default function Playground({
@@ -14,21 +60,27 @@ export default function Playground({
14
60
  dependencies,
15
61
  height = 480,
16
62
  showConsole = true,
63
+ askAiToFix = true,
17
64
  }: Props) {
18
65
  return (
19
66
  <div className="playground" style={{ height }}>
20
- <Sandpack
67
+ <SandpackProvider
21
68
  template={template}
22
69
  files={files}
23
70
  customSetup={dependencies ? { dependencies } : undefined}
24
71
  theme="dark"
25
- options={{
26
- showConsole,
27
- showConsoleButton: true,
28
- editorHeight: height,
29
- editorWidthPercentage: 50,
30
- }}
31
- />
72
+ >
73
+ <SandpackLayout>
74
+ <SandpackCodeEditor style={{ height }} />
75
+ <SandpackPreview style={{ height }} showOpenInCodeSandbox={false} />
76
+ </SandpackLayout>
77
+ {showConsole && <SandpackConsole />}
78
+ {askAiToFix && (
79
+ <div className="playground-toolbar">
80
+ <AskAiButton />
81
+ </div>
82
+ )}
83
+ </SandpackProvider>
32
84
  </div>
33
85
  );
34
86
  }
@@ -1,5 +1,6 @@
1
1
  import { Check, X } from "lucide-react";
2
2
  import { useId, useState } from "react";
3
+ import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
3
4
  import { useProgress } from "../../lib/progress/useProgress";
4
5
 
5
6
  interface Props {
@@ -15,6 +16,7 @@ export default function Quiz({ question, options, answer, explanation, id, multi
15
16
  const reactId = useId();
16
17
  const questionId = id ?? `quiz:${reactId}:${question.slice(0, 40)}`;
17
18
  const { state, recordQuiz } = useProgress();
19
+ const aiEnabled = useAiEnabled();
18
20
 
19
21
  const previous = state.quizzes[questionId];
20
22
  const [chosen, setChosen] = useState<number[]>(previous?.chosen ?? []);
@@ -97,6 +99,22 @@ export default function Quiz({ question, options, answer, explanation, id, multi
97
99
  )}
98
100
  </div>
99
101
  {submitted && explanation && <p className="quiz-exp">{explanation}</p>}
102
+ {submitted && previous && !previous.correct && aiEnabled && (
103
+ <button
104
+ type="button"
105
+ className="hz-helpme"
106
+ onClick={() =>
107
+ dispatchAssist({
108
+ kind: "quizFix",
109
+ question,
110
+ chosen: previous.chosen.map((i) => options[i] ?? `(option ${i})`),
111
+ correct: Array.from(correctSet).map((i) => options[i] ?? `(option ${i})`),
112
+ })
113
+ }
114
+ >
115
+ Why is this wrong?
116
+ </button>
117
+ )}
100
118
  </fieldset>
101
119
  );
102
120
  }
package/src/index.ts CHANGED
@@ -17,6 +17,11 @@ export {
17
17
  streamChat,
18
18
  } from "./lib/ai/client.ts";
19
19
  export { type AssistantContext, buildContext } from "./lib/ai/context.ts";
20
+ export {
21
+ type AssistantIntent,
22
+ type AssistantPrompt,
23
+ buildAssistantPrompt,
24
+ } from "./lib/ai/prompts.ts";
20
25
  // Content collection helpers (built on top of astro:content).
21
26
  export {
22
27
  getStep,
@@ -75,6 +75,12 @@ const desc = description ?? tagline;
75
75
  <meta property="og:title" content={pageTitle} />
76
76
  <meta property="og:description" content={desc} />
77
77
  <meta property="og:type" content="website" />
78
+ {/* Optional consumer-side <head> injections. Forwarded by every
79
+ * page wrapper (Home, TutorialLanding, TutorialStep / TutorialLayout)
80
+ * so scaffolds can preload fonts, attach per-page OG images, add
81
+ * JSON-LD structured data, or wire verification meta without
82
+ * forking BaseLayout. See README for usage. */}
83
+ <slot name="head" />
78
84
  </head>
79
85
  <body style={`--hz-page-max-width: ${resolvedMaxWidth}; --hz-page-padding-x: ${pagePaddingX}; --hz-nav-height: 3rem;`}>
80
86
  {nav === "full" && (
@@ -42,6 +42,7 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
42
42
  faviconUrl={faviconUrl}
43
43
  repoUrl={repoUrl}
44
44
  >
45
+ <slot name="head" slot="head" />
45
46
  <div class="layout">
46
47
  <div class="sidebar-wrap">
47
48
  <Sidebar tutorial={tutorial} steps={steps} currentStepSlug={currentStepSlug} />
@@ -82,9 +83,28 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
82
83
  id="tt-route"
83
84
  data-tutorial-slug={tutorial.id}
84
85
  data-step-slug={currentStepSlug}
86
+ data-tutorial-title={tutorial.data.title}
85
87
  data-tutorial-steps={JSON.stringify(stepSlugs)}
86
88
  hidden
87
89
  ></div>
90
+ {/*
91
+ Raw MDX source of the current step + selected metadata. CopyPrompt,
92
+ "Copy step as Markdown", deep-link row, and the BYOA touchpoints
93
+ read this at click time. Replacing "<" with "<" inside JSON
94
+ keeps a stray "</script>" or "<!--" in the step body from
95
+ terminating the script tag early.
96
+ */}
97
+ <script
98
+ id="tt-step-data"
99
+ type="application/json"
100
+ set:html={JSON.stringify({
101
+ tutorialSlug: tutorial.id,
102
+ tutorialTitle: tutorial.data.title,
103
+ stepSlug: currentStepSlug,
104
+ stepTitle: currentStep.data.title,
105
+ stepSource: currentStep.body ?? "",
106
+ }).replace(/</g, "\\u003c")}
107
+ ></script>
88
108
  <script>
89
109
  // Record last-visited + popularity markers from a data marker
90
110
  // (set in the markup above). Using a normal <script> + relative
@@ -158,23 +178,30 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
158
178
 
159
179
  <style>
160
180
  /* Draw the sidebar/main divider as a background line on the grid
161
- * itself: 1px-thin vertical strip at the 280px column boundary,
181
+ * itself: 1px-thin vertical strip at the sidebar column boundary,
162
182
  * from the top of the layout to the bottom. The grid auto-stretches
163
183
  * to fit its content, so the line runs all the way down to the
164
184
  * footer regardless of step length — without piggybacking on the
165
- * (sticky, viewport-height) sidebar's own border. */
185
+ * (sticky, viewport-height) sidebar's own border.
186
+ *
187
+ * `--sb-w` keeps the column track and the divider stop in lockstep
188
+ * so widening the sidebar at one breakpoint doesn't desync the line. */
166
189
  .layout {
190
+ --sb-w: 280px;
167
191
  display: grid;
168
- grid-template-columns: 280px minmax(0, 1fr);
192
+ grid-template-columns: var(--sb-w) minmax(0, 1fr);
169
193
  min-height: 100dvh;
170
194
  background: linear-gradient(
171
195
  to right,
172
- transparent 280px,
173
- var(--color-border) 280px,
174
- var(--color-border) 281px,
175
- transparent 281px
196
+ transparent var(--sb-w),
197
+ var(--color-border) var(--sb-w),
198
+ var(--color-border) calc(var(--sb-w) + 1px),
199
+ transparent calc(var(--sb-w) + 1px)
176
200
  ) no-repeat;
177
201
  }
202
+ @media (min-width: 1280px) {
203
+ .layout { --sb-w: 320px; }
204
+ }
178
205
  .sidebar-wrap {
179
206
  position: sticky;
180
207
  top: var(--hz-nav-height, 3rem);
@@ -192,10 +219,11 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
192
219
  color: var(--color-muted);
193
220
  }
194
221
  .step-title {
222
+ font-family: var(--font-display, var(--font-sans));
195
223
  font-size: clamp(1.75rem, 3vw, 2.25rem);
196
- font-weight: 700;
224
+ font-weight: var(--font-weight-display, 700);
197
225
  margin: 0.3rem 0 0.5rem;
198
- letter-spacing: -0.02em;
226
+ letter-spacing: var(--tracking-display, -0.02em);
199
227
  line-height: 1.15;
200
228
  }
201
229
  .step-dur {
@@ -0,0 +1,81 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { AssistantIntent } from "./prompts";
3
+
4
+ /**
5
+ * Document-scoped event that Family A touchpoints (HelpMe, quiz wrong
6
+ * answer, checkpoint nudge, selection ask, playground fix, step
7
+ * footer) dispatch to ask the in-app tutor to open with a pre-seeded
8
+ * intent. ChatButton is the single listener — it owns the context
9
+ * and config needed to render the intent into seedMessages.
10
+ */
11
+ export const ASSIST_EVENT = "handzon:assist";
12
+
13
+ /**
14
+ * Dispatched by ChatButton once on mount, and reflected as
15
+ * `document.documentElement.dataset.handzonAi = "ready"` for islands
16
+ * that mount after the chat (read the flag) or before (await the
17
+ * event). Family A islands use `useAiEnabled()` to hide themselves
18
+ * when the tutor isn't on this page.
19
+ */
20
+ export const ASSIST_READY_EVENT = "handzon:ai-ready";
21
+
22
+ export interface AssistEventDetail {
23
+ intent: AssistantIntent;
24
+ }
25
+
26
+ /**
27
+ * Fire a touchpoint intent. ChatButton receives, builds the prompt
28
+ * with the live AssistantContext it already holds, and opens the
29
+ * panel with the seed user turn.
30
+ */
31
+ export function dispatchAssist(intent: AssistantIntent): void {
32
+ if (typeof document === "undefined") return;
33
+ const detail: AssistEventDetail = { intent };
34
+ document.dispatchEvent(new CustomEvent(ASSIST_EVENT, { detail }));
35
+ }
36
+
37
+ /**
38
+ * Reactive flag: is the in-app tutor mounted on this page? Family A
39
+ * islands return null when false so the affordance is hidden
40
+ * entirely when `aiConfig.enabled === false`.
41
+ */
42
+ export function useAiEnabled(): boolean {
43
+ const [enabled, setEnabled] = useState(false);
44
+ useEffect(() => {
45
+ if (document.documentElement.dataset.handzonAi === "ready") {
46
+ setEnabled(true);
47
+ return;
48
+ }
49
+ const onReady = () => setEnabled(true);
50
+ document.addEventListener(ASSIST_READY_EVENT, onReady);
51
+ return () => document.removeEventListener(ASSIST_READY_EVENT, onReady);
52
+ }, []);
53
+ return enabled;
54
+ }
55
+
56
+ /**
57
+ * Reactive flag: is a specific tool (config.tools.X) enabled by the
58
+ * host page's aiConfig? Returns false until ChatButton publishes the
59
+ * tools manifest on mount. Family A islands that gate on a tool
60
+ * (Playground's "Ask AI to fix") use this in addition to
61
+ * useAiEnabled.
62
+ */
63
+ export function useAiTool(name: string): boolean {
64
+ const enabled = useAiEnabled();
65
+ const [tools, setTools] = useState<Record<string, boolean>>({});
66
+ useEffect(() => {
67
+ function read() {
68
+ const raw = document.documentElement.dataset.handzonAiTools;
69
+ if (!raw) return;
70
+ try {
71
+ setTools(JSON.parse(raw));
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ }
76
+ read();
77
+ document.addEventListener(ASSIST_READY_EVENT, read);
78
+ return () => document.removeEventListener(ASSIST_READY_EVENT, read);
79
+ }, []);
80
+ return enabled && tools[name] === true;
81
+ }
@@ -0,0 +1,126 @@
1
+ import type { ChatMessage } from "./client";
2
+ import type { AssistantContext } from "./context";
3
+
4
+ export type AssistantIntent =
5
+ | { kind: "unstuck"; topic?: string }
6
+ | {
7
+ kind: "quizFix";
8
+ question: string;
9
+ chosen: string[];
10
+ correct: string[];
11
+ }
12
+ | { kind: "checkpoint"; label: string }
13
+ | { kind: "selection"; text: string }
14
+ | { kind: "playgroundFix"; files: Record<string, string> }
15
+ | { kind: "explainStep" }
16
+ | { kind: "recap" };
17
+
18
+ export interface AssistantPrompt {
19
+ /** Ready-to-render Markdown a learner can paste anywhere. */
20
+ markdown: string;
21
+ /** Seed messages for ChatPanel — first user turn, no assistant turn. */
22
+ seedMessages: ChatMessage[];
23
+ /** URL-encoded prompt for each external agent. */
24
+ deepLinks: {
25
+ cursor: string;
26
+ claude: string;
27
+ chatgpt: string;
28
+ vscode: string;
29
+ };
30
+ }
31
+
32
+ function header(ctx: AssistantContext): string {
33
+ return [`Tutorial: ${ctx.tutorial.title}`, `Step: ${ctx.currentStep.title}`].join("\n");
34
+ }
35
+
36
+ function renderIntent(ctx: AssistantContext, intent: AssistantIntent): string {
37
+ switch (intent.kind) {
38
+ case "unstuck":
39
+ return intent.topic
40
+ ? `I'm stuck on "${intent.topic}". Help me figure out what to do next without giving the whole answer.`
41
+ : `I'm stuck on this step. Help me figure out what to do next without giving the whole answer.`;
42
+ case "quizFix":
43
+ return [
44
+ `I got a quiz question wrong and want to understand why.`,
45
+ ``,
46
+ `Question: ${intent.question}`,
47
+ `I chose: ${intent.chosen.join(", ") || "(nothing)"}`,
48
+ `Correct answer: ${intent.correct.join(", ")}`,
49
+ ``,
50
+ `Explain why my choice is wrong without restating the correct answer verbatim — help me see what concept I'm missing.`,
51
+ ].join("\n");
52
+ case "checkpoint":
53
+ return `I'm not sure how to complete this checkpoint: "${intent.label}". Walk me through what to try next.`;
54
+ case "selection":
55
+ return [
56
+ `I have a question about this part of the tutorial:`,
57
+ ``,
58
+ "```",
59
+ intent.text,
60
+ "```",
61
+ ``,
62
+ `Can you explain it in context of where I am?`,
63
+ ].join("\n");
64
+ case "playgroundFix": {
65
+ const files = Object.entries(intent.files)
66
+ .map(([path, body]) => `### \`${path}\`\n\n\`\`\`\n${body}\n\`\`\``)
67
+ .join("\n\n");
68
+ return [
69
+ `My playground code isn't working as expected. Here are the current files:`,
70
+ ``,
71
+ files,
72
+ ``,
73
+ `What's wrong, and what should I change? Point me at the bug; don't just rewrite the file.`,
74
+ ].join("\n");
75
+ }
76
+ case "explainStep":
77
+ return [
78
+ `Walk me through what this step is asking me to do and why it matters.`,
79
+ ``,
80
+ `Step content:`,
81
+ ``,
82
+ ctx.currentStep.source,
83
+ ].join("\n");
84
+ case "recap":
85
+ return `Give me a one-paragraph recap of what I've learned so far in this tutorial.`;
86
+ }
87
+ }
88
+
89
+ function buildMarkdown(ctx: AssistantContext, intent: AssistantIntent): string {
90
+ return [header(ctx), "", renderIntent(ctx, intent)].join("\n");
91
+ }
92
+
93
+ /**
94
+ * Cursor's anysphere deep link expects a `text` query param.
95
+ * Claude.ai and ChatGPT use `?q=...`. VS Code can launch a chat
96
+ * extension via the `vscode://` scheme; we use the generic
97
+ * `vscode://GitHub.copilot-chat/chat?prompt=...` form, which is
98
+ * the closest thing to a stable contract today.
99
+ */
100
+ function buildDeepLinks(prompt: string): AssistantPrompt["deepLinks"] {
101
+ const enc = encodeURIComponent(prompt);
102
+ return {
103
+ cursor: `cursor://anysphere.cursor-deeplink/prompt?text=${enc}`,
104
+ claude: `https://claude.ai/new?q=${enc}`,
105
+ chatgpt: `https://chat.openai.com/?q=${enc}`,
106
+ vscode: `vscode://GitHub.copilot-chat/chat?prompt=${enc}`,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Render an assistant context + a small intent payload into the three
112
+ * surfaces every touchpoint needs: chat seed messages, copyable
113
+ * markdown, and per-agent deep links. Each Family A/B touchpoint is a
114
+ * one-liner around this function.
115
+ */
116
+ export function buildAssistantPrompt(
117
+ context: AssistantContext,
118
+ intent: AssistantIntent,
119
+ ): AssistantPrompt {
120
+ const markdown = buildMarkdown(context, intent);
121
+ return {
122
+ markdown,
123
+ seedMessages: [{ role: "user", content: markdown }],
124
+ deepLinks: buildDeepLinks(markdown),
125
+ };
126
+ }