handzon-core 0.6.2 → 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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +102 -3
  3. package/src/components/ai/ChatButton.tsx +52 -4
  4. package/src/components/ai/ChatPanel.tsx +95 -27
  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/auth/UserMenu.tsx +6 -4
  10. package/src/components/home/ActiveFilterChips.tsx +2 -5
  11. package/src/components/home/FilterBar.tsx +9 -11
  12. package/src/components/home/Pagination.tsx +1 -3
  13. package/src/components/home/ResumeRail.tsx +3 -1
  14. package/src/components/mdx/Checkpoint.tsx +73 -4
  15. package/src/components/mdx/CopyPrompt.astro +10 -0
  16. package/src/components/mdx/CopyPrompt.tsx +56 -0
  17. package/src/components/mdx/HelpMe.astro +10 -0
  18. package/src/components/mdx/HelpMe.tsx +29 -0
  19. package/src/components/mdx/Playground.tsx +61 -9
  20. package/src/components/mdx/Quiz.tsx +18 -0
  21. package/src/components/ui/Dropdown.tsx +2 -10
  22. package/src/components/ui/MultiSelect.tsx +3 -17
  23. package/src/index.ts +27 -27
  24. package/src/layouts/TutorialLayout.astro +19 -0
  25. package/src/lib/ai/assist.ts +81 -0
  26. package/src/lib/ai/prompts.ts +126 -0
  27. package/src/lib/ai/stepData.ts +74 -0
  28. package/src/lib/mdx-components.ts +4 -0
  29. package/src/lib/progress/remote.ts +93 -24
  30. package/src/lib/progress/types.ts +23 -0
  31. package/src/lib/progress/useProgress.ts +12 -0
  32. package/src/pages/TutorialStep.astro +12 -1
  33. package/src/pages/paths.ts +2 -1
  34. package/src/server/auth/config.ts +2 -1
  35. package/src/server/auth/schema.ts +1 -8
  36. package/src/server/auth.ts +85 -6
  37. package/src/server/db/schema.ts +54 -1
  38. package/src/server/handlers/helpInbox.ts +45 -0
  39. package/src/server/handlers/mcp.ts +72 -0
  40. package/src/server/handlers/progress.ts +8 -29
  41. package/src/server/handlers/progressEvents.ts +68 -0
  42. package/src/server/handlers/tutorialStats.ts +6 -3
  43. package/src/server/mcp/protocol.ts +99 -0
  44. package/src/server/mcp/server.ts +94 -0
  45. package/src/server/mcp/tools.ts +175 -0
  46. package/src/server/mcp/writeTools.ts +407 -0
  47. package/src/server/progress.ts +86 -0
  48. package/src/server/progressBus.ts +51 -0
  49. package/src/server/tokens.ts +80 -0
  50. package/src/server/verify/evaluator.ts +134 -0
  51. package/src/types/ai.ts +6 -0
  52. package/styles/components/assist.css +101 -0
  53. package/styles/components/checkpoint.css +29 -0
  54. package/styles/components.css +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.6.2",
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. */
@@ -121,6 +215,11 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
121
215
  estimatedDuration: z.string().optional(),
122
216
  prerequisites: z.array(z.string()).default([]),
123
217
  nextTutorial: z.string().optional(),
218
+ // TODO(handzon): `cover` and `icon` are accepted by the schema for
219
+ // forward compatibility, but no page currently renders them
220
+ // (Home cards, TutorialLanding, OG meta all ignore them). Wire them
221
+ // up in TutorialCard and BaseLayout's OG tags before promoting
222
+ // cover art in author-facing docs and skills.
124
223
  cover: image().optional(),
125
224
  icon: z.union([z.string(), image()]).optional(),
126
225
  steps: z.array(z.string()).optional(),
@@ -1,7 +1,10 @@
1
1
  import { Sparkles } from "lucide-react";
2
- import { useState } from "react";
3
- import type { AiConfig } from "../../types/ai";
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";
4
5
  import type { AssistantContext } from "../../lib/ai/context";
6
+ import { buildAssistantPrompt } from "../../lib/ai/prompts";
7
+ import type { AiConfig } from "../../types/ai";
5
8
  import ChatPanel from "./ChatPanel";
6
9
 
7
10
  interface Props {
@@ -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
  }
@@ -3,9 +3,9 @@ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
5
  import remarkGfm from "remark-gfm";
6
- import type { AiConfig } from "../../types/ai";
7
6
  import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
8
7
  import type { AssistantContext } from "../../lib/ai/context";
8
+ import type { AiConfig } from "../../types/ai";
9
9
  import ByokSetup from "./ByokSetup";
10
10
 
11
11
  interface Props {
@@ -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);
@@ -45,25 +53,18 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
45
53
 
46
54
  // Keep the latest message in view as chunks stream in (and on every
47
55
  // send / clear). Without this, long responses scroll out of frame.
56
+ // The deps aren't read inside the effect — they're triggers, so the
57
+ // effect re-runs when a new chunk arrives or streaming flips. Biome's
58
+ // exhaustive-deps lint would have us remove them; that would break
59
+ // the autoscroll. Keep the suppression scoped to this single effect.
60
+ // biome-ignore lint/correctness/useExhaustiveDependencies: messages + streaming are intentional triggers
48
61
  useEffect(() => {
49
62
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
50
63
  }, [messages, streaming]);
51
64
 
52
- async function send() {
53
- const trimmed = input.trim();
54
- if (!trimmed || streaming) return;
55
-
56
- if (needsKey) {
57
- setByokOpen(true);
58
- return;
59
- }
60
-
61
- const next: ChatMessage[] = [...messages, { role: "user", content: trimmed }];
62
- setMessages(next);
63
- setInput("");
65
+ async function runStream(next: ChatMessage[]) {
64
66
  setStreaming(true);
65
67
  setError(null);
66
-
67
68
  abortRef.current = new AbortController();
68
69
  try {
69
70
  const stream = await streamChat({
@@ -102,6 +103,73 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
102
103
  }
103
104
  }
104
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
+
105
173
  function clear() {
106
174
  setMessages([]);
107
175
  setError(null);
@@ -162,8 +230,8 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
162
230
  <KeyRound size={22} aria-hidden="true" />
163
231
  <h3>API key required</h3>
164
232
  <p>
165
- {config.name} needs an API key to answer questions. Add one to get started —
166
- it's stored in this browser only.
233
+ {config.name} needs an API key to answer questions. Add one to get started — it's
234
+ stored in this browser only.
167
235
  </p>
168
236
  <button type="button" onClick={() => setByokOpen(true)}>
169
237
  Set up key
@@ -197,7 +265,7 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
197
265
  <div className="chat-msg chat-msg-assistant">
198
266
  <span className="chat-role">{config.name}</span>
199
267
  <div className="chat-content">
200
- <span className="chat-thinking" aria-label="Thinking">
268
+ <span className="chat-thinking" role="status" aria-label="Thinking">
201
269
  <span /> <span /> <span />
202
270
  </span>
203
271
  </div>
@@ -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
+ }
@@ -53,7 +53,7 @@ export default function UserMenu() {
53
53
  }
54
54
  const sess = (await sessRes.json()) as Session | null;
55
55
  const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
56
- setSession(sess && sess.user ? sess : null);
56
+ setSession(sess?.user ? sess : null);
57
57
  setCsrfToken(csrf?.csrfToken ?? null);
58
58
  } catch {
59
59
  if (!cancelled) {
@@ -79,9 +79,9 @@ export default function UserMenu() {
79
79
  // long-form context.
80
80
  const fullLabel = user?.name ?? user?.email ?? "Signed in";
81
81
  const displayName = user
82
- ? (user.name ? user.name.trim().split(/\s+/)[0] : null) ??
82
+ ? ((user.name ? user.name.trim().split(/\s+/)[0] : null) ??
83
83
  (user.email ? user.email.split("@")[0] : null) ??
84
- "Signed in"
84
+ "Signed in")
85
85
  : "";
86
86
 
87
87
  return (
@@ -95,7 +95,9 @@ export default function UserMenu() {
95
95
  {fullLabel.trim().charAt(0).toUpperCase()}
96
96
  </span>
97
97
  )}
98
- <span className="um-name" title={fullLabel}>{displayName}</span>
98
+ <span className="um-name" title={fullLabel}>
99
+ {displayName}
100
+ </span>
99
101
  <form method="post" action="/api/auth/signout">
100
102
  <input type="hidden" name="csrfToken" value={csrfToken} />
101
103
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
@@ -40,6 +40,7 @@ export default function ActiveFilterChips({
40
40
  }
41
41
 
42
42
  return (
43
+ // biome-ignore lint/a11y/useSemanticElements: <fieldset> requires <legend> and carries form-control semantics; this row groups filter-removal buttons.
43
44
  <div className="active-filters" role="group" aria-label="Active filters">
44
45
  {q && (
45
46
  <button
@@ -79,11 +80,7 @@ export default function ActiveFilterChips({
79
80
  <X size={12} aria-hidden="true" />
80
81
  </button>
81
82
  ))}
82
- <button
83
- type="button"
84
- className="active-filter-clear"
85
- onClick={onClearAll}
86
- >
83
+ <button type="button" className="active-filter-clear" onClick={onClearAll}>
87
84
  Clear all
88
85
  </button>
89
86
  </div>