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.
- package/package.json +1 -1
- package/src/collections.ts +97 -3
- package/src/components/ai/ChatButton.tsx +51 -3
- package/src/components/ai/ChatPanel.tsx +86 -23
- package/src/components/ai/CopyStep.tsx +44 -0
- package/src/components/ai/OpenInAgent.tsx +55 -0
- package/src/components/ai/SelectionAsk.tsx +98 -0
- package/src/components/ai/StepHelp.tsx +31 -0
- package/src/components/mdx/Checkpoint.tsx +66 -2
- package/src/components/mdx/CopyPrompt.astro +10 -0
- package/src/components/mdx/CopyPrompt.tsx +56 -0
- package/src/components/mdx/HelpMe.astro +10 -0
- package/src/components/mdx/HelpMe.tsx +29 -0
- package/src/components/mdx/Playground.tsx +61 -9
- package/src/components/mdx/Quiz.tsx +18 -0
- package/src/index.ts +5 -0
- package/src/layouts/TutorialLayout.astro +19 -0
- package/src/lib/ai/assist.ts +81 -0
- package/src/lib/ai/prompts.ts +126 -0
- package/src/lib/ai/stepData.ts +74 -0
- package/src/lib/mdx-components.ts +4 -0
- package/src/lib/progress/remote.ts +86 -25
- package/src/lib/progress/types.ts +23 -0
- package/src/lib/progress/useProgress.ts +8 -4
- package/src/pages/TutorialStep.astro +12 -1
- package/src/server/auth.ts +84 -1
- package/src/server/db/schema.ts +53 -0
- package/src/server/handlers/helpInbox.ts +45 -0
- package/src/server/handlers/mcp.ts +72 -0
- package/src/server/handlers/progress.ts +7 -51
- package/src/server/handlers/progressEvents.ts +68 -0
- package/src/server/mcp/protocol.ts +99 -0
- package/src/server/mcp/server.ts +94 -0
- package/src/server/mcp/tools.ts +175 -0
- package/src/server/mcp/writeTools.ts +407 -0
- package/src/server/progress.ts +86 -0
- package/src/server/progressBus.ts +51 -0
- package/src/server/tokens.ts +80 -0
- package/src/server/verify/evaluator.ts +134 -0
- package/src/types/ai.ts +6 -0
- package/styles/components/assist.css +101 -0
- package/styles/components/checkpoint.css +29 -0
- package/styles/components.css +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "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"
|
package/src/collections.ts
CHANGED
|
@@ -86,20 +86,114 @@ export function tutorialsLoader(): Loader {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
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={() =>
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
}
|