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.
- package/package.json +1 -1
- package/src/collections.ts +102 -3
- package/src/components/ai/ChatButton.tsx +52 -4
- package/src/components/ai/ChatPanel.tsx +95 -27
- 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/auth/UserMenu.tsx +6 -4
- package/src/components/home/ActiveFilterChips.tsx +2 -5
- package/src/components/home/FilterBar.tsx +9 -11
- package/src/components/home/Pagination.tsx +1 -3
- package/src/components/home/ResumeRail.tsx +3 -1
- package/src/components/mdx/Checkpoint.tsx +73 -4
- 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/components/ui/Dropdown.tsx +2 -10
- package/src/components/ui/MultiSelect.tsx +3 -17
- package/src/index.ts +27 -27
- 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 +93 -24
- package/src/lib/progress/types.ts +23 -0
- package/src/lib/progress/useProgress.ts +12 -0
- package/src/pages/TutorialStep.astro +12 -1
- package/src/pages/paths.ts +2 -1
- package/src/server/auth/config.ts +2 -1
- package/src/server/auth/schema.ts +1 -8
- package/src/server/auth.ts +85 -6
- package/src/server/db/schema.ts +54 -1
- package/src/server/handlers/helpInbox.ts +45 -0
- package/src/server/handlers/mcp.ts +72 -0
- package/src/server/handlers/progress.ts +8 -29
- package/src/server/handlers/progressEvents.ts +68 -0
- package/src/server/handlers/tutorialStats.ts +6 -3
- 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. */
|
|
@@ -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
|
|
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={() =>
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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}>
|
|
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>
|