handzon-core 0.12.1 → 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.12.1",
3
+ "version": "0.13.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"
@@ -19,10 +19,15 @@ import { readdir, readFile } from "node:fs/promises";
19
19
  import { join, relative, resolve } from "node:path";
20
20
  import type { Loader } from "astro/loaders";
21
21
  import { glob } from "astro/loaders";
22
+ import { createHeroMediaSchema } from "./lib/heroMedia";
22
23
 
23
24
  const TUTORIALS_REL = "src/content/tutorials";
24
25
  const INDEX_FILE = "_index.json";
25
26
 
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return !!value && typeof value === "object" && !Array.isArray(value);
29
+ }
30
+
26
31
  async function readIndexOrder(dir: string): Promise<string[]> {
27
32
  try {
28
33
  const raw = await readFile(join(dir, INDEX_FILE), "utf8");
@@ -35,6 +40,114 @@ async function readIndexOrder(dir: string): Promise<string[]> {
35
40
  }
36
41
  }
37
42
 
43
+ async function readTutorialTrackIds(tutorialSlug: string): Promise<string[]> {
44
+ try {
45
+ const raw = await readFile(join(resolve(TUTORIALS_REL), tutorialSlug, "_meta.json"), "utf8");
46
+ const parsed = JSON.parse(raw) as { tracks?: unknown };
47
+ return Array.isArray(parsed.tracks)
48
+ ? parsed.tracks
49
+ .map((track) => (isRecord(track) && typeof track.id === "string" ? track.id : null))
50
+ .filter((id): id is string => !!id)
51
+ : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function isStarterSpec(value: unknown): value is StarterSpec {
58
+ return isRecord(value) && typeof value.kind === "string";
59
+ }
60
+
61
+ function isVerifySpec(value: unknown): value is VerifySpec {
62
+ return isRecord(value) && typeof value.id === "string" && Array.isArray(value.checks);
63
+ }
64
+
65
+ function isTrackMap<T>(
66
+ value: TrackScoped<T> | undefined,
67
+ isShared: (v: unknown) => v is T,
68
+ ): value is TrackMap<T> {
69
+ return isRecord(value) && !isShared(value);
70
+ }
71
+
72
+ function validateTrackMapCoverage<T>({
73
+ entryId,
74
+ label,
75
+ map,
76
+ trackIds,
77
+ }: {
78
+ entryId: string;
79
+ label: string;
80
+ map: TrackMap<T>;
81
+ trackIds: string[];
82
+ }) {
83
+ if (trackIds.length === 0) {
84
+ throw new Error(
85
+ `[handzon] ${entryId}: ${label} is keyed by track, but the tutorial declares no tracks.`,
86
+ );
87
+ }
88
+ const declared = new Set(trackIds);
89
+ const actual = Object.keys(map);
90
+ const unknown = actual.filter((id) => !declared.has(id));
91
+ const missing = trackIds.filter((id) => !(id in map));
92
+ if (unknown.length > 0 || missing.length > 0) {
93
+ const details = [
94
+ unknown.length > 0 ? `unknown: ${unknown.join(", ")}` : null,
95
+ missing.length > 0 ? `missing: ${missing.join(", ")}` : null,
96
+ ]
97
+ .filter(Boolean)
98
+ .join("; ");
99
+ throw new Error(`[handzon] ${entryId}: ${label} track coverage mismatch (${details}).`);
100
+ }
101
+ }
102
+
103
+ function validateTrackIdsInBody({
104
+ body,
105
+ entryId,
106
+ trackIds,
107
+ }: {
108
+ body: string;
109
+ entryId: string;
110
+ trackIds: string[];
111
+ }) {
112
+ const trackRe = /<Track\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
113
+ const declared = new Set(trackIds);
114
+ for (;;) {
115
+ const m = trackRe.exec(body);
116
+ if (m === null) break;
117
+ const id = m[1] ?? m[2] ?? m[3];
118
+ if (!id) continue;
119
+ if (trackIds.length === 0) {
120
+ throw new Error(
121
+ `[handzon] step ${entryId}: <Track id="${id}"> is used, but the tutorial declares no tracks.`,
122
+ );
123
+ }
124
+ if (!declared.has(id)) {
125
+ throw new Error(
126
+ `[handzon] step ${entryId}: <Track id="${id}"> is not declared in the tutorial's tracks.`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ function validateVerifySpecCheckpoint({
133
+ entryId,
134
+ ids,
135
+ trackId,
136
+ verify,
137
+ }: {
138
+ entryId: string;
139
+ ids: Set<string>;
140
+ trackId?: string;
141
+ verify: VerifySpec;
142
+ }) {
143
+ if (!ids.has(verify.id)) {
144
+ const prefix = trackId ? `verify.${trackId}.id` : "verify.id";
145
+ throw new Error(
146
+ `[handzon] step ${entryId}: ${prefix} "${verify.id}" has no matching <Checkpoint id="…"> in the step body. Either add a <Checkpoint id="${verify.id}" …/> or remove the verify block.`,
147
+ );
148
+ }
149
+ }
150
+
38
151
  /**
39
152
  * Custom loader that scans tutorials/<slug>/_meta.json for each tutorial.
40
153
  * Tutorial order is driven by `_index.json` at the tutorials root; folder
@@ -106,10 +219,18 @@ export function stepsLoader(): Loader {
106
219
  load: async (args) => {
107
220
  await inner.load(args);
108
221
  const checkpointRe = /<Checkpoint\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
222
+ const trackIdCache = new Map<string, Promise<string[]>>();
109
223
  for (const value of args.store.values()) {
110
- const verify = (value.data as { verify?: { id?: string } } | undefined)?.verify;
111
- if (!verify?.id) continue;
224
+ const { tutorialSlug } = parseStepCollectionId(value.id);
225
+ const trackIdsPromise =
226
+ trackIdCache.get(tutorialSlug) ?? readTutorialTrackIds(tutorialSlug);
227
+ trackIdCache.set(tutorialSlug, trackIdsPromise);
228
+ const trackIds = await trackIdsPromise;
112
229
  const body = value.body ?? "";
230
+ validateTrackIdsInBody({ body, entryId: value.id, trackIds });
231
+
232
+ const verify = (value.data as { verify?: TrackScoped<VerifySpec> } | undefined)?.verify;
233
+ if (!verify) continue;
113
234
  const ids = new Set<string>();
114
235
  checkpointRe.lastIndex = 0;
115
236
  for (;;) {
@@ -118,10 +239,19 @@ export function stepsLoader(): Loader {
118
239
  const id = m[1] ?? m[2] ?? m[3];
119
240
  if (id) ids.add(id);
120
241
  }
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
- );
242
+
243
+ if (isTrackMap(verify, isVerifySpec)) {
244
+ validateTrackMapCoverage({
245
+ entryId: `step ${value.id}`,
246
+ label: "verify",
247
+ map: verify,
248
+ trackIds,
249
+ });
250
+ for (const [trackId, spec] of Object.entries(verify)) {
251
+ validateVerifySpecCheckpoint({ entryId: value.id, ids, trackId, verify: spec });
252
+ }
253
+ } else {
254
+ validateVerifySpecCheckpoint({ entryId: value.id, ids, verify });
125
255
  }
126
256
  }
127
257
  },
@@ -216,64 +346,171 @@ export const starterSchema = z.discriminatedUnion("kind", [
216
346
 
217
347
  export type StarterSpec = z.infer<typeof starterSchema>;
218
348
 
219
- /** Schema for tutorial step entries. */
220
- export const stepsSchema = z.object({
221
- title: z.string(),
222
- duration: z.string().optional(),
223
- summary: z.string().optional(),
224
- ai: z.boolean().optional(),
225
- verify: verifySchema.optional(),
349
+ export const trackSchema = z.object({
350
+ id: z
351
+ .string()
352
+ .min(1)
353
+ .regex(/^[a-z0-9][a-z0-9_-]*$/i, "Use a stable id like py, ts, or rust."),
354
+ label: z.string().min(1),
226
355
  });
227
356
 
228
- /** Schema for tutorial entries. Pass through Astro's image() helper. */
229
- export function tutorialsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
357
+ export type TutorialTrack = z.infer<typeof trackSchema>;
358
+ export type TrackMap<T> = Record<string, T>;
359
+ export type TrackScoped<T> = T | TrackMap<T>;
360
+
361
+ export const verifyByTrackSchema = z.record(verifySchema);
362
+ export const trackScopedVerifySchema = z.union([verifySchema, verifyByTrackSchema]);
363
+
364
+ export const starterByTrackSchema = z.record(starterSchema);
365
+ export const trackScopedStarterSchema = z.union([starterSchema, starterByTrackSchema]);
366
+
367
+ export type TrackScopedVerifySpec = z.infer<typeof trackScopedVerifySchema>;
368
+ export type TrackScopedStarterSpec = z.infer<typeof trackScopedStarterSchema>;
369
+
370
+ export function resolveForTrack<T>(
371
+ value: TrackScoped<T> | undefined,
372
+ trackId: string | undefined,
373
+ isShared: (v: unknown) => v is T,
374
+ ): T | undefined {
375
+ if (!value) return undefined;
376
+ if (isShared(value)) return value;
377
+ return trackId ? value[trackId] : undefined;
378
+ }
379
+
380
+ export function resolveStarterForTrack(
381
+ starter: TrackScoped<StarterSpec> | undefined,
382
+ trackId: string | undefined,
383
+ ): StarterSpec | undefined {
384
+ return resolveForTrack(starter, trackId, isStarterSpec);
385
+ }
386
+
387
+ export function resolveVerifyForTrack(
388
+ verify: TrackScoped<VerifySpec> | undefined,
389
+ trackId: string | undefined,
390
+ ): VerifySpec | undefined {
391
+ return resolveForTrack(verify, trackId, isVerifySpec);
392
+ }
393
+
394
+ function parseStepCollectionId(id: string): { tutorialSlug: string; stepFile: string } {
395
+ const slash = id.indexOf("/");
396
+ if (slash < 0) {
397
+ throw new Error(`Unrecognized step id: ${id}`);
398
+ }
399
+ return { tutorialSlug: id.slice(0, slash), stepFile: id.slice(slash + 1) };
400
+ }
401
+
402
+ function refineTrackConfig(
403
+ data: {
404
+ tracks?: TutorialTrack[];
405
+ defaultTrack?: string;
406
+ starter?: TrackScoped<StarterSpec>;
407
+ },
408
+ ctx: z.RefinementCtx,
409
+ ) {
410
+ const trackIds = data.tracks?.map((track) => track.id) ?? [];
411
+ const duplicated = trackIds.filter((id, index) => trackIds.indexOf(id) !== index);
412
+ for (const id of new Set(duplicated)) {
413
+ ctx.addIssue({
414
+ code: z.ZodIssueCode.custom,
415
+ path: ["tracks"],
416
+ message: `Duplicate track id "${id}".`,
417
+ });
418
+ }
419
+ if (data.defaultTrack && !trackIds.includes(data.defaultTrack)) {
420
+ ctx.addIssue({
421
+ code: z.ZodIssueCode.custom,
422
+ path: ["defaultTrack"],
423
+ message: `defaultTrack "${data.defaultTrack}" is not declared in tracks.`,
424
+ });
425
+ }
426
+ if (isTrackMap(data.starter, isStarterSpec)) {
427
+ const actual = Object.keys(data.starter);
428
+ const unknown = actual.filter((id) => !trackIds.includes(id));
429
+ const missing = trackIds.filter((id) => !(id in data.starter!));
430
+ if (trackIds.length === 0) {
431
+ ctx.addIssue({
432
+ code: z.ZodIssueCode.custom,
433
+ path: ["starter"],
434
+ message: "Per-track starter requires declared tracks.",
435
+ });
436
+ }
437
+ for (const id of unknown) {
438
+ ctx.addIssue({
439
+ code: z.ZodIssueCode.custom,
440
+ path: ["starter", id],
441
+ message: `Unknown starter track "${id}".`,
442
+ });
443
+ }
444
+ if (missing.length > 0) {
445
+ ctx.addIssue({
446
+ code: z.ZodIssueCode.custom,
447
+ path: ["starter"],
448
+ message: `Missing starter for track(s): ${missing.join(", ")}.`,
449
+ });
450
+ }
451
+ }
452
+ }
453
+
454
+ /** Schema for tutorial step entries. Pass through Astro's image() helper. */
455
+ export function stepsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
230
456
  return z.object({
231
457
  title: z.string(),
232
- description: z.string(),
233
- author: z
234
- .object({
235
- name: z.string(),
236
- url: z.string().url().optional(),
237
- avatar: image().optional(),
238
- })
239
- .optional(),
240
- publishedAt: z.coerce.date().optional(),
241
- updatedAt: z.coerce.date().optional(),
242
- tags: z.array(z.string()).default([]),
243
- difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
244
- estimatedDuration: z.string().optional(),
245
- prerequisites: z.array(z.string()).default([]),
246
- nextTutorial: z.string().optional(),
247
- // TODO(handzon): `cover` and `icon` are accepted by the schema for
248
- // forward compatibility, but no page currently renders them
249
- // (Home cards, TutorialLanding, OG meta all ignore them). Wire them
250
- // up in TutorialCard and BaseLayout's OG tags before promoting
251
- // cover art in author-facing docs and skills.
252
- cover: image().optional(),
253
- icon: z.union([z.string(), image()]).optional(),
254
- steps: z.array(z.string()).optional(),
255
- gated: z.boolean().default(false),
256
- showProgress: z.boolean().default(true),
257
- feedbackUrl: z.string().url().optional(),
258
- starter: starterSchema.optional(),
259
- ai: z
260
- .object({
261
- enabled: z.boolean().optional(),
262
- name: z.string().optional(),
263
- tagline: z.string().optional(),
264
- greeting: z.string().optional(),
265
- avatar: image().optional(),
266
- persona: z.string().optional(),
267
- tone: z.enum(["socratic", "direct", "encouraging"]).optional(),
268
- provider: z.string().optional(),
269
- model: z.string().optional(),
270
- byok: z.enum(["required", "optional", "disabled"]).optional(),
271
- references: z.array(z.string()).default([]),
272
- allowedDomains: z.array(z.string()).default([]),
273
- disabledSkills: z.array(z.string()).default([]),
274
- enableSuggestPlaygroundEdit: z.boolean().default(false),
275
- includeFutureSteps: z.boolean().optional(),
276
- })
277
- .optional(),
458
+ duration: z.string().optional(),
459
+ summary: z.string().optional(),
460
+ ai: z.boolean().optional(),
461
+ heroMedia: createHeroMediaSchema(z, image).optional(),
462
+ verify: trackScopedVerifySchema.optional(),
278
463
  });
279
464
  }
465
+
466
+ /** Schema for tutorial entries. Pass through Astro's image() helper. */
467
+ export function tutorialsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
468
+ return z
469
+ .object({
470
+ title: z.string(),
471
+ description: z.string(),
472
+ author: z
473
+ .object({
474
+ name: z.string(),
475
+ url: z.string().url().optional(),
476
+ avatar: image().optional(),
477
+ })
478
+ .optional(),
479
+ publishedAt: z.coerce.date().optional(),
480
+ updatedAt: z.coerce.date().optional(),
481
+ tags: z.array(z.string()).default([]),
482
+ tracks: z.array(trackSchema).default([]),
483
+ defaultTrack: z.string().min(1).optional(),
484
+ difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
485
+ estimatedDuration: z.string().optional(),
486
+ prerequisites: z.array(z.string()).default([]),
487
+ nextTutorial: z.string().optional(),
488
+ cover: image().optional(),
489
+ icon: z.union([z.string(), image()]).optional(),
490
+ steps: z.array(z.string()).optional(),
491
+ gated: z.boolean().default(false),
492
+ showProgress: z.boolean().default(true),
493
+ feedbackUrl: z.string().url().optional(),
494
+ starter: trackScopedStarterSchema.optional(),
495
+ ai: z
496
+ .object({
497
+ enabled: z.boolean().optional(),
498
+ name: z.string().optional(),
499
+ tagline: z.string().optional(),
500
+ greeting: z.string().optional(),
501
+ avatar: image().optional(),
502
+ persona: z.string().optional(),
503
+ tone: z.enum(["socratic", "direct", "encouraging"]).optional(),
504
+ provider: z.string().optional(),
505
+ model: z.string().optional(),
506
+ byok: z.enum(["required", "optional", "disabled"]).optional(),
507
+ references: z.array(z.string()).default([]),
508
+ allowedDomains: z.array(z.string()).default([]),
509
+ disabledSkills: z.array(z.string()).default([]),
510
+ enableSuggestPlaygroundEdit: z.boolean().default(false),
511
+ includeFutureSteps: z.boolean().optional(),
512
+ })
513
+ .optional(),
514
+ })
515
+ .superRefine(refineTrackConfig);
516
+ }
@@ -3,6 +3,7 @@ import { withBase } from "../lib/base";
3
3
  import type { TutorialEntry, StepEntry } from "../lib/content";
4
4
  import { parseStepId } from "../lib/content";
5
5
  import Progress from "./Progress.tsx";
6
+ import TrackSelector from "./TrackSelector.tsx";
6
7
 
7
8
  interface Props {
8
9
  tutorial: TutorialEntry;
@@ -30,6 +31,13 @@ const slug = tutorial.id;
30
31
  {tutorial.data.showProgress && (
31
32
  <Progress tutorialSlug={slug} totalSteps={steps.length} client:load />
32
33
  )}
34
+ {tutorial.data.tracks.length > 1 && (
35
+ <TrackSelector
36
+ tracks={tutorial.data.tracks}
37
+ defaultTrack={tutorial.data.defaultTrack}
38
+ client:load
39
+ />
40
+ )}
33
41
  </div>
34
42
 
35
43
  <ol class="sb-steps">
@@ -0,0 +1,77 @@
1
+ ---
2
+ import { Image } from "astro:assets";
3
+ import type { ImageMetadata } from "astro";
4
+ import { withBase } from "../lib/base";
5
+ import Embed from "./mdx/Embed.astro";
6
+
7
+ type StepHeroMedia =
8
+ | {
9
+ kind: "image";
10
+ src: string | ImageMetadata;
11
+ alt: string;
12
+ caption?: string;
13
+ }
14
+ | {
15
+ kind: "video";
16
+ src: string;
17
+ title: string;
18
+ aspect?: string;
19
+ type?: "iframe" | "video";
20
+ caption?: string;
21
+ };
22
+
23
+ interface Props {
24
+ media: StepHeroMedia;
25
+ }
26
+
27
+ const { media } = Astro.props;
28
+
29
+ ---
30
+ <figure class="step-hero-media">
31
+ {media.kind === "video" ? (
32
+ <Embed
33
+ src={media.src}
34
+ title={media.title}
35
+ aspect={media.aspect}
36
+ type={media.type}
37
+ />
38
+ ) : typeof media.src !== "string" ? (
39
+ <Image
40
+ src={media.src}
41
+ alt={media.alt}
42
+ class="step-hero-image"
43
+ widths={[720, 960, 1200, 1440]}
44
+ sizes="(min-width: 900px) 720px, 100vw"
45
+ />
46
+ ) : (
47
+ <img
48
+ src={withBase(media.src)}
49
+ alt={media.alt}
50
+ class="step-hero-image"
51
+ loading="eager"
52
+ />
53
+ )}
54
+ {media.caption && <figcaption>{media.caption}</figcaption>}
55
+ </figure>
56
+
57
+ <style>
58
+ .step-hero-media {
59
+ margin: 0 0 2rem;
60
+ }
61
+ .step-hero-media :global(.embed) {
62
+ margin: 0;
63
+ }
64
+ .step-hero-image {
65
+ display: block;
66
+ width: 100%;
67
+ height: auto;
68
+ border: var(--border-default, 2px) solid var(--color-border);
69
+ background: var(--color-surface);
70
+ }
71
+ figcaption {
72
+ margin-top: 0.65rem;
73
+ color: var(--color-muted);
74
+ font-size: 0.85em;
75
+ line-height: 1.5;
76
+ }
77
+ </style>
@@ -0,0 +1,62 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { useProgress } from "../lib/progress/useProgress";
3
+ import { resolveActiveTrack, type TrackOption, trackStyleText } from "../lib/tracks";
4
+
5
+ interface Props {
6
+ tracks: TrackOption[];
7
+ defaultTrack?: string;
8
+ }
9
+
10
+ function applyTrackStyle(trackId: string | undefined) {
11
+ if (typeof document === "undefined" || !trackId) return;
12
+ document.documentElement.dataset.track = trackId;
13
+ let style = document.getElementById("handzon-track-style") as HTMLStyleElement | null;
14
+ if (!style) {
15
+ style = document.createElement("style");
16
+ style.id = "handzon-track-style";
17
+ document.head.appendChild(style);
18
+ }
19
+ style.textContent = trackStyleText(trackId);
20
+ }
21
+
22
+ export default function TrackSelector({ tracks, defaultTrack }: Props) {
23
+ const { state, setPref } = useProgress();
24
+ const activeTrack = useMemo(
25
+ () =>
26
+ resolveActiveTrack({
27
+ tracks,
28
+ preferredTrack: state.prefs.track,
29
+ defaultTrack,
30
+ }),
31
+ [tracks, state.prefs.track, defaultTrack],
32
+ );
33
+
34
+ useEffect(() => {
35
+ applyTrackStyle(activeTrack);
36
+ }, [activeTrack]);
37
+
38
+ if (tracks.length < 2 || !activeTrack) return null;
39
+
40
+ return (
41
+ <section className="track-selector" aria-label="Tutorial track">
42
+ <div className="track-selector-label">Track</div>
43
+ <div className="track-selector-list">
44
+ {tracks.map((track) => {
45
+ const selected = track.id === activeTrack;
46
+ return (
47
+ <button
48
+ type="button"
49
+ key={track.id}
50
+ className="track-selector-option"
51
+ data-active={selected ? "true" : "false"}
52
+ aria-pressed={selected}
53
+ onClick={() => setPref("track", track.id)}
54
+ >
55
+ {track.label}
56
+ </button>
57
+ );
58
+ })}
59
+ </div>
60
+ </section>
61
+ );
62
+ }
@@ -1,9 +1,12 @@
1
1
  import { Sparkles } from "lucide-react";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { ASSIST_EVENT, ASSIST_READY_EVENT, type AssistEventDetail } from "../../lib/ai/assist";
4
4
  import type { ChatMessage } from "../../lib/ai/client";
5
5
  import type { AssistantContext } from "../../lib/ai/context";
6
6
  import { buildAssistantPrompt } from "../../lib/ai/prompts";
7
+ import { useProgress } from "../../lib/progress/useProgress";
8
+ import { stripInactiveTrackBlocks } from "../../lib/track-source";
9
+ import { resolveActiveTrack } from "../../lib/tracks";
7
10
  import type { AiConfig } from "../../types/ai";
8
11
  import ChatPanel from "./ChatPanel";
9
12
 
@@ -13,12 +16,33 @@ interface Props {
13
16
  }
14
17
 
15
18
  export default function ChatButton({ config, context }: Props) {
19
+ const { state } = useProgress();
16
20
  const [open, setOpen] = useState(false);
17
21
  const [seed, setSeed] = useState<ChatMessage[] | undefined>(undefined);
18
22
  // Bumped on each new assist seed so ChatPanel remounts with the
19
23
  // new initialMessages even if the panel is already open from a
20
24
  // previous intent or FAB click.
21
25
  const [seedToken, setSeedToken] = useState(0);
26
+ const trackContext = useMemo<AssistantContext>(() => {
27
+ const activeTrack = resolveActiveTrack({
28
+ tracks: context.tutorial.tracks,
29
+ preferredTrack: state.prefs.track,
30
+ defaultTrack: context.tutorial.defaultTrack,
31
+ });
32
+ if (!activeTrack) return context;
33
+ return {
34
+ ...context,
35
+ tutorial: { ...context.tutorial, track: activeTrack },
36
+ currentStep: {
37
+ ...context.currentStep,
38
+ source: stripInactiveTrackBlocks(context.currentStep.source, activeTrack),
39
+ },
40
+ priorSteps: context.priorSteps.map((step) => ({
41
+ ...step,
42
+ source: stripInactiveTrackBlocks(step.source, activeTrack),
43
+ })),
44
+ };
45
+ }, [context, state.prefs.track]);
22
46
 
23
47
  // Tell Family A islands (HelpMe, Checkpoint nudge, …) that the tutor
24
48
  // is mounted on this page. Set the dataset flag for islands that
@@ -40,14 +64,14 @@ export default function ChatButton({ config, context }: Props) {
40
64
  function onAssist(e: Event) {
41
65
  const detail = (e as CustomEvent<AssistEventDetail>).detail;
42
66
  if (!detail?.intent) return;
43
- const { seedMessages } = buildAssistantPrompt(context, detail.intent);
67
+ const { seedMessages } = buildAssistantPrompt(trackContext, detail.intent);
44
68
  setSeed(seedMessages);
45
69
  setSeedToken((t) => t + 1);
46
70
  setOpen(true);
47
71
  }
48
72
  document.addEventListener(ASSIST_EVENT, onAssist);
49
73
  return () => document.removeEventListener(ASSIST_EVENT, onAssist);
50
- }, [context]);
74
+ }, [trackContext]);
51
75
 
52
76
  if (!config.enabled) return null;
53
77
 
@@ -70,7 +94,7 @@ export default function ChatButton({ config, context }: Props) {
70
94
  open={open}
71
95
  onOpenChange={setOpen}
72
96
  config={config}
73
- context={context}
97
+ context={trackContext}
74
98
  initialMessages={seed}
75
99
  />
76
100
  </>
@@ -224,6 +224,16 @@ export default function ChatPanel({ open, onOpenChange, config, context, initial
224
224
 
225
225
  <div className="chat-meta">
226
226
  On: <strong>{context.currentStep.title}</strong>
227
+ {context.tutorial.track && (
228
+ <>
229
+ {" "}
230
+ · Track:{" "}
231
+ <strong>
232
+ {context.tutorial.tracks.find((track) => track.id === context.tutorial.track)
233
+ ?.label ?? context.tutorial.track}
234
+ </strong>
235
+ </>
236
+ )}
227
237
  </div>
228
238
 
229
239
  {needsKey ? (