stagent 0.3.6 → 0.4.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 (72) hide show
  1. package/README.md +11 -0
  2. package/dist/cli.js +39 -10
  3. package/drizzle.config.ts +3 -1
  4. package/package.json +3 -1
  5. package/src/app/api/book/bookmarks/route.ts +73 -0
  6. package/src/app/api/book/progress/route.ts +79 -0
  7. package/src/app/api/book/regenerate/route.ts +111 -0
  8. package/src/app/api/book/stage/route.ts +13 -0
  9. package/src/app/api/chat/conversations/[id]/respond/route.ts +19 -20
  10. package/src/app/api/chat/conversations/[id]/route.ts +2 -1
  11. package/src/app/api/documents/[id]/route.ts +34 -2
  12. package/src/app/api/documents/route.ts +91 -0
  13. package/src/app/api/settings/runtime/route.ts +29 -8
  14. package/src/app/book/page.tsx +14 -0
  15. package/src/app/chat/page.tsx +7 -1
  16. package/src/app/globals.css +375 -0
  17. package/src/app/projects/[id]/page.tsx +31 -6
  18. package/src/app/{playbook → user-guide}/[slug]/page.tsx +12 -2
  19. package/src/app/{playbook → user-guide}/page.tsx +2 -2
  20. package/src/app/workflows/[id]/page.tsx +28 -2
  21. package/src/components/book/book-reader.tsx +801 -0
  22. package/src/components/book/chapter-generation-bar.tsx +109 -0
  23. package/src/components/book/content-blocks.tsx +432 -0
  24. package/src/components/book/path-progress.tsx +33 -0
  25. package/src/components/book/path-selector.tsx +42 -0
  26. package/src/components/book/try-it-now.tsx +164 -0
  27. package/src/components/chat/chat-activity-indicator.tsx +92 -0
  28. package/src/components/chat/chat-message-list.tsx +3 -0
  29. package/src/components/chat/chat-message.tsx +22 -6
  30. package/src/components/chat/chat-permission-request.tsx +5 -1
  31. package/src/components/chat/chat-question.tsx +3 -0
  32. package/src/components/chat/chat-shell.tsx +130 -19
  33. package/src/components/chat/conversation-list.tsx +8 -2
  34. package/src/components/playbook/adoption-heatmap.tsx +1 -1
  35. package/src/components/playbook/journey-card.tsx +1 -1
  36. package/src/components/playbook/playbook-card.tsx +1 -1
  37. package/src/components/playbook/playbook-detail-view.tsx +15 -5
  38. package/src/components/playbook/playbook-homepage.tsx +1 -1
  39. package/src/components/playbook/playbook-updated-badge.tsx +1 -1
  40. package/src/components/projects/project-detail.tsx +147 -27
  41. package/src/components/projects/project-form-sheet.tsx +6 -2
  42. package/src/components/projects/project-list.tsx +1 -1
  43. package/src/components/settings/runtime-timeout-section.tsx +117 -37
  44. package/src/components/shared/app-sidebar.tsx +7 -1
  45. package/src/components/shared/command-palette.tsx +4 -4
  46. package/src/hooks/use-chapter-generation.ts +255 -0
  47. package/src/lib/agents/claude-agent.ts +12 -6
  48. package/src/lib/book/chapter-generator.ts +193 -0
  49. package/src/lib/book/chapter-mapping.ts +91 -0
  50. package/src/lib/book/content.ts +251 -0
  51. package/src/lib/book/markdown-parser.ts +317 -0
  52. package/src/lib/book/reading-paths.ts +82 -0
  53. package/src/lib/book/types.ts +152 -0
  54. package/src/lib/book/update-detector.ts +157 -0
  55. package/src/lib/chat/codex-engine.ts +537 -0
  56. package/src/lib/chat/context-builder.ts +18 -4
  57. package/src/lib/chat/engine.ts +116 -39
  58. package/src/lib/chat/model-discovery.ts +13 -5
  59. package/src/lib/chat/permission-bridge.ts +14 -2
  60. package/src/lib/chat/stagent-tools.ts +2 -0
  61. package/src/lib/chat/system-prompt.ts +16 -1
  62. package/src/lib/chat/tools/chat-history-tools.ts +177 -0
  63. package/src/lib/chat/tools/document-tools.ts +204 -0
  64. package/src/lib/chat/tools/settings-tools.ts +29 -3
  65. package/src/lib/chat/types.ts +8 -1
  66. package/src/lib/constants/settings.ts +1 -0
  67. package/src/lib/data/chat.ts +83 -2
  68. package/src/lib/data/clear.ts +8 -0
  69. package/src/lib/db/bootstrap.ts +24 -0
  70. package/src/lib/db/schema.ts +32 -0
  71. package/src/lib/docs/types.ts +9 -0
  72. /package/src/app/api/{playbook → user-guide}/status/route.ts +0 -0
@@ -0,0 +1,801 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import {
5
+ BookOpen,
6
+ BookmarkPlus,
7
+ BookmarkMinus,
8
+ ChevronLeft,
9
+ ChevronRight,
10
+ List,
11
+ Settings2,
12
+ Clock,
13
+ Check,
14
+ Sparkles,
15
+ Bookmark as BookmarkIcon,
16
+ } from "lucide-react";
17
+ import { Button } from "@/components/ui/button";
18
+ import {
19
+ Sheet,
20
+ SheetContent,
21
+ SheetHeader,
22
+ SheetTitle,
23
+ SheetTrigger,
24
+ } from "@/components/ui/sheet";
25
+ import { Slider } from "@/components/ui/slider";
26
+ import { cn } from "@/lib/utils";
27
+ import { PARTS } from "@/lib/book/content";
28
+ import type { BookChapter, ReaderPreferences, ReadingProgress, Bookmark } from "@/lib/book/types";
29
+ import { DEFAULT_READER_PREFS } from "@/lib/book/types";
30
+ import { useRouter } from "next/navigation";
31
+ import { ContentBlockRenderer } from "./content-blocks";
32
+ import { TryItNow } from "./try-it-now";
33
+ import { ChapterGenerationBar } from "./chapter-generation-bar";
34
+ import { PathSelector } from "./path-selector";
35
+ import { PathProgress } from "./path-progress";
36
+ import { getReadingPath, getNextPathChapter, isChapterInPath } from "@/lib/book/reading-paths";
37
+
38
+ const PREFS_KEY = "stagent-book-prefs";
39
+ const PROGRESS_KEY = "stagent-book-progress";
40
+ const SYNC_DEBOUNCE_MS = 2000;
41
+
42
+ function loadPrefs(): ReaderPreferences {
43
+ if (typeof window === "undefined") return DEFAULT_READER_PREFS;
44
+ try {
45
+ const saved = localStorage.getItem(PREFS_KEY);
46
+ return saved ? { ...DEFAULT_READER_PREFS, ...JSON.parse(saved) } : DEFAULT_READER_PREFS;
47
+ } catch {
48
+ return DEFAULT_READER_PREFS;
49
+ }
50
+ }
51
+
52
+ function savePrefs(prefs: ReaderPreferences) {
53
+ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
54
+ }
55
+
56
+ function loadProgress(): Record<string, ReadingProgress> {
57
+ if (typeof window === "undefined") return {};
58
+ try {
59
+ const saved = localStorage.getItem(PROGRESS_KEY);
60
+ return saved ? JSON.parse(saved) : {};
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ function saveProgress(progress: Record<string, ReadingProgress>) {
67
+ localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
68
+ }
69
+
70
+ /** Debounced sync of a single chapter's progress to the DB */
71
+ function syncProgressToDb(chapterId: string, pct: number, scrollPosition: number) {
72
+ fetch("/api/book/progress", {
73
+ method: "PUT",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ chapterId, progress: pct, scrollPosition }),
76
+ }).catch(() => {
77
+ // Silently fail — localStorage is the primary fallback
78
+ });
79
+ }
80
+
81
+ export function BookReader({ chapters: CHAPTERS }: { chapters: BookChapter[] }) {
82
+ const router = useRouter();
83
+ const [currentChapter, setCurrentChapter] = useState<BookChapter>(CHAPTERS[0]);
84
+ const [prefs, setPrefs] = useState<ReaderPreferences>(DEFAULT_READER_PREFS);
85
+ const [progress, setProgress] = useState<Record<string, ReadingProgress>>({});
86
+ const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
87
+ const [tocOpen, setTocOpen] = useState(false);
88
+ const [settingsOpen, setSettingsOpen] = useState(false);
89
+ const [tocTab, setTocTab] = useState<"chapters" | "bookmarks">("chapters");
90
+ const [activePath, setActivePath] = useState<string | null>(null);
91
+ const [recommendedPath, setRecommendedPath] = useState<string | null>(null);
92
+ const contentRef = useRef<HTMLDivElement>(null);
93
+ const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
94
+
95
+ // Sync currentChapter when chapters prop refreshes (e.g., after regeneration)
96
+ useEffect(() => {
97
+ const refreshed = CHAPTERS.find((ch) => ch.id === currentChapter.id);
98
+ if (refreshed && refreshed !== currentChapter) {
99
+ setCurrentChapter(refreshed);
100
+ }
101
+ }, [CHAPTERS]); // eslint-disable-line react-hooks/exhaustive-deps
102
+
103
+ // Load progress from DB first, fall back to localStorage
104
+ useEffect(() => {
105
+ setPrefs(loadPrefs());
106
+ const localProgress = loadProgress();
107
+ setProgress(localProgress);
108
+
109
+ // Fetch from DB and merge (DB wins for higher progress)
110
+ fetch("/api/book/progress")
111
+ .then((r) => r.json())
112
+ .then((dbProgress: Record<string, { progress: number; scrollPosition: number; lastReadAt: string }>) => {
113
+ setProgress((prev) => {
114
+ const merged = { ...prev };
115
+ for (const [chId, dbEntry] of Object.entries(dbProgress)) {
116
+ const localEntry = merged[chId];
117
+ if (!localEntry || dbEntry.progress > localEntry.progress) {
118
+ merged[chId] = {
119
+ chapterId: chId,
120
+ progress: dbEntry.progress,
121
+ scrollPosition: dbEntry.scrollPosition,
122
+ lastReadAt: dbEntry.lastReadAt,
123
+ };
124
+ }
125
+ }
126
+ saveProgress(merged);
127
+ return merged;
128
+ });
129
+ })
130
+ .catch(() => {
131
+ // DB unavailable — use localStorage only
132
+ });
133
+
134
+ // Fetch bookmarks from DB
135
+ fetch("/api/book/bookmarks")
136
+ .then((r) => r.json())
137
+ .then((bms: Bookmark[]) => setBookmarks(bms))
138
+ .catch(() => {});
139
+ }, []);
140
+
141
+ // Load reading path preference and fetch recommendation
142
+ useEffect(() => {
143
+ const saved = localStorage.getItem("stagent-book-path");
144
+ if (saved) setActivePath(saved);
145
+
146
+ fetch("/api/book/stage")
147
+ .then((res) => (res.ok ? res.json() : null))
148
+ .then((data) => {
149
+ if (data?.recommendedPath) setRecommendedPath(data.recommendedPath);
150
+ })
151
+ .catch(() => {}); // Silently fail — recommendation is optional
152
+ }, []);
153
+
154
+ const handlePathChange = useCallback((pathId: string | null) => {
155
+ setActivePath(pathId);
156
+ if (pathId) {
157
+ localStorage.setItem("stagent-book-path", pathId);
158
+ } else {
159
+ localStorage.removeItem("stagent-book-path");
160
+ }
161
+ }, []);
162
+
163
+ // Track scroll progress
164
+ useEffect(() => {
165
+ const el = contentRef.current;
166
+ if (!el) return;
167
+
168
+ const handleScroll = () => {
169
+ const scrollTop = el.scrollTop;
170
+ const scrollHeight = el.scrollHeight - el.clientHeight;
171
+ if (scrollHeight <= 0) return;
172
+ const pct = Math.min(1, scrollTop / scrollHeight);
173
+
174
+ setProgress((prev) => {
175
+ const highWater = Math.max(pct, prev[currentChapter.id]?.progress ?? 0);
176
+ const updated = {
177
+ ...prev,
178
+ [currentChapter.id]: {
179
+ chapterId: currentChapter.id,
180
+ progress: highWater,
181
+ scrollPosition: scrollTop,
182
+ lastReadAt: new Date().toISOString(),
183
+ },
184
+ };
185
+ saveProgress(updated);
186
+
187
+ // Debounced DB sync
188
+ if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
189
+ syncTimerRef.current = setTimeout(() => {
190
+ syncProgressToDb(currentChapter.id, highWater, scrollTop);
191
+ }, SYNC_DEBOUNCE_MS);
192
+
193
+ return updated;
194
+ });
195
+ };
196
+
197
+ el.addEventListener("scroll", handleScroll, { passive: true });
198
+ return () => {
199
+ el.removeEventListener("scroll", handleScroll);
200
+ // Flush pending sync on cleanup
201
+ if (syncTimerRef.current) {
202
+ clearTimeout(syncTimerRef.current);
203
+ const entry = progress[currentChapter.id];
204
+ if (entry) {
205
+ syncProgressToDb(currentChapter.id, entry.progress, entry.scrollPosition);
206
+ }
207
+ }
208
+ };
209
+ // eslint-disable-next-line react-hooks/exhaustive-deps
210
+ }, [currentChapter.id]);
211
+
212
+ const updatePrefs = useCallback((patch: Partial<ReaderPreferences>) => {
213
+ setPrefs((prev) => {
214
+ const next = { ...prev, ...patch };
215
+ savePrefs(next);
216
+ return next;
217
+ });
218
+ }, []);
219
+
220
+ const goToChapter = useCallback(
221
+ (chapter: BookChapter, scrollTo?: number) => {
222
+ setCurrentChapter(chapter);
223
+ setTocOpen(false);
224
+ if (scrollTo !== undefined && scrollTo > 0) {
225
+ // Delay to let content render
226
+ setTimeout(() => {
227
+ contentRef.current?.scrollTo({ top: scrollTo, behavior: "smooth" });
228
+ }, 100);
229
+ } else {
230
+ contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
231
+ }
232
+ },
233
+ []
234
+ );
235
+
236
+ // Bookmark: add for current position
237
+ const addBookmark = useCallback(() => {
238
+ const el = contentRef.current;
239
+ if (!el) return;
240
+
241
+ // Find the nearest visible section
242
+ let nearestSection: { id: string; title: string } | null = null;
243
+ for (const section of currentChapter.sections) {
244
+ const sectionEl = document.getElementById(section.id);
245
+ if (sectionEl) {
246
+ const rect = sectionEl.getBoundingClientRect();
247
+ const containerRect = el.getBoundingClientRect();
248
+ if (rect.top <= containerRect.top + 200) {
249
+ nearestSection = section;
250
+ }
251
+ }
252
+ }
253
+
254
+ const label = nearestSection
255
+ ? `Ch. ${currentChapter.number}: ${nearestSection.title}`
256
+ : `Ch. ${currentChapter.number}: ${currentChapter.title}`;
257
+
258
+ fetch("/api/book/bookmarks", {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({
262
+ chapterId: currentChapter.id,
263
+ sectionId: nearestSection?.id ?? null,
264
+ scrollPosition: el.scrollTop,
265
+ label,
266
+ }),
267
+ })
268
+ .then((r) => r.json())
269
+ .then((bm: Bookmark) => {
270
+ setBookmarks((prev) => [...prev, bm]);
271
+ })
272
+ .catch(() => {});
273
+ }, [currentChapter]);
274
+
275
+ // Bookmark: remove
276
+ const removeBookmark = useCallback((id: string) => {
277
+ fetch(`/api/book/bookmarks?id=${encodeURIComponent(id)}`, { method: "DELETE" })
278
+ .then(() => {
279
+ setBookmarks((prev) => prev.filter((b) => b.id !== id));
280
+ })
281
+ .catch(() => {});
282
+ }, []);
283
+
284
+ // Navigate to bookmark
285
+ const goToBookmark = useCallback(
286
+ (bm: Bookmark) => {
287
+ const chapter = CHAPTERS.find((ch) => ch.id === bm.chapterId);
288
+ if (chapter) {
289
+ goToChapter(chapter, bm.scrollPosition);
290
+ }
291
+ },
292
+ [goToChapter]
293
+ );
294
+
295
+ // Check if current position has a bookmark nearby
296
+ const currentChapterBookmarks = bookmarks.filter((b) => b.chapterId === currentChapter.id);
297
+
298
+ const currentIndex = CHAPTERS.findIndex((ch) => ch.id === currentChapter.id);
299
+
300
+ // Path-aware navigation: when a path is active, prev/next follow the path order
301
+ const prevChapter = (() => {
302
+ if (!activePath) {
303
+ return currentIndex > 0 ? CHAPTERS[currentIndex - 1] : null;
304
+ }
305
+ const path = getReadingPath(activePath);
306
+ if (!path) return currentIndex > 0 ? CHAPTERS[currentIndex - 1] : null;
307
+ const pathIdx = path.chapterIds.indexOf(currentChapter.id);
308
+ if (pathIdx <= 0) return null;
309
+ return CHAPTERS.find((ch) => ch.id === path.chapterIds[pathIdx - 1]) ?? null;
310
+ })();
311
+
312
+ const nextChapter = (() => {
313
+ if (!activePath) {
314
+ return currentIndex < CHAPTERS.length - 1 ? CHAPTERS[currentIndex + 1] : null;
315
+ }
316
+ const nextId = getNextPathChapter(activePath, currentChapter.id);
317
+ if (!nextId) return null;
318
+ return CHAPTERS.find((ch) => ch.id === nextId) ?? null;
319
+ })();
320
+
321
+ // Keyboard navigation: Left/Right arrow keys for chapter nav
322
+ useEffect(() => {
323
+ const handleKeyDown = (e: KeyboardEvent) => {
324
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
325
+ if (e.key === "ArrowLeft" && prevChapter) {
326
+ e.preventDefault();
327
+ goToChapter(prevChapter);
328
+ } else if (e.key === "ArrowRight" && nextChapter) {
329
+ e.preventDefault();
330
+ goToChapter(nextChapter);
331
+ }
332
+ };
333
+ window.addEventListener("keydown", handleKeyDown);
334
+ return () => window.removeEventListener("keydown", handleKeyDown);
335
+ }, [prevChapter, nextChapter, goToChapter]);
336
+
337
+ const chaptersByPart = (() => {
338
+ const grouped = new Map<number, BookChapter[]>();
339
+ for (const ch of CHAPTERS) {
340
+ const part = ch.part.number;
341
+ if (!grouped.has(part)) grouped.set(part, []);
342
+ grouped.get(part)!.push(ch);
343
+ }
344
+ return grouped;
345
+ })();
346
+
347
+ const fontFamilyClass =
348
+ prefs.fontFamily === "serif"
349
+ ? "font-serif"
350
+ : prefs.fontFamily === "mono"
351
+ ? "font-mono"
352
+ : "font-sans";
353
+
354
+ const themeClass = "book-reader-container";
355
+
356
+ const overallProgress =
357
+ CHAPTERS.length > 0
358
+ ? CHAPTERS.reduce((sum, ch) => sum + (progress[ch.id]?.progress ?? 0), 0) /
359
+ CHAPTERS.length
360
+ : 0;
361
+
362
+ const completedChapters = CHAPTERS.filter((ch) => (progress[ch.id]?.progress ?? 0) >= 0.9).length;
363
+
364
+ return (
365
+ <div className={cn("flex flex-col h-[calc(100vh-3.5rem)]", themeClass)} data-book-theme={prefs.theme}>
366
+ {/* Top bar */}
367
+ <header className="flex items-center justify-between border-b border-border px-4 py-2 shrink-0">
368
+ <div className="flex items-center gap-2">
369
+ {/* TOC toggle */}
370
+ <Sheet open={tocOpen} onOpenChange={setTocOpen}>
371
+ <SheetTrigger asChild>
372
+ <Button variant="ghost" size="icon" className="h-8 w-8">
373
+ <List className="h-4 w-4" />
374
+ </Button>
375
+ </SheetTrigger>
376
+ <SheetContent side="left" className="w-80">
377
+ <SheetHeader className="p-4">
378
+ <SheetTitle className="flex items-center gap-2">
379
+ <BookOpen className="h-4 w-4" />
380
+ Contents
381
+ </SheetTitle>
382
+ </SheetHeader>
383
+ <div className="px-4 pb-4 space-y-4 overflow-y-auto">
384
+ {/* Tab switcher */}
385
+ <div className="flex gap-1 p-1 rounded-lg bg-muted">
386
+ <button
387
+ onClick={() => setTocTab("chapters")}
388
+ className={cn(
389
+ "flex-1 text-xs font-medium py-1.5 rounded-md transition-colors cursor-pointer",
390
+ tocTab === "chapters"
391
+ ? "bg-background shadow-sm"
392
+ : "text-muted-foreground hover:text-foreground"
393
+ )}
394
+ >
395
+ Chapters
396
+ </button>
397
+ <button
398
+ onClick={() => setTocTab("bookmarks")}
399
+ className={cn(
400
+ "flex-1 text-xs font-medium py-1.5 rounded-md transition-colors cursor-pointer",
401
+ tocTab === "bookmarks"
402
+ ? "bg-background shadow-sm"
403
+ : "text-muted-foreground hover:text-foreground"
404
+ )}
405
+ >
406
+ Bookmarks{bookmarks.length > 0 && ` (${bookmarks.length})`}
407
+ </button>
408
+ </div>
409
+
410
+ {/* Overall progress summary */}
411
+ {tocTab === "chapters" && (
412
+ <div className="flex items-center gap-3 px-1">
413
+ <div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
414
+ <div
415
+ className="h-full bg-primary rounded-full transition-all"
416
+ style={{ width: `${overallProgress * 100}%` }}
417
+ />
418
+ </div>
419
+ <span className="text-xs text-muted-foreground whitespace-nowrap">
420
+ {completedChapters}/{CHAPTERS.length} complete
421
+ </span>
422
+ </div>
423
+ )}
424
+
425
+ {tocTab === "chapters" ? (
426
+ <div className="space-y-6">
427
+ {/* Reading path selector */}
428
+ <PathSelector
429
+ activePath={activePath}
430
+ recommendedPath={recommendedPath}
431
+ onSelectPath={handlePathChange}
432
+ />
433
+
434
+ {PARTS.map((part) => (
435
+ <div key={part.number}>
436
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
437
+ Part {part.number}: {part.title}
438
+ </p>
439
+ <p className="text-xs text-muted-foreground mb-3">
440
+ {part.description}
441
+ </p>
442
+ <div className="space-y-1">
443
+ {(chaptersByPart.get(part.number) ?? []).map((ch) => {
444
+ const chProgress = progress[ch.id]?.progress ?? 0;
445
+ const chPct = Math.round(chProgress * 100);
446
+ const inPath = !activePath || isChapterInPath(activePath, ch.id);
447
+ return (
448
+ <button
449
+ key={ch.id}
450
+ onClick={() => goToChapter(ch)}
451
+ className={cn(
452
+ "w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer",
453
+ ch.id === currentChapter.id
454
+ ? "bg-primary/10 text-primary font-medium"
455
+ : "hover:bg-muted",
456
+ !inPath && "opacity-40"
457
+ )}
458
+ >
459
+ <div className="flex items-center justify-between">
460
+ <span>
461
+ {ch.number}. {ch.title}
462
+ </span>
463
+ <span className="flex items-center gap-1.5">
464
+ {!inPath && (
465
+ <span className="text-[10px] text-muted-foreground">Not in path</span>
466
+ )}
467
+ {ch.sections.length === 0 ? (
468
+ <Sparkles className="h-3.5 w-3.5 text-muted-foreground/40" />
469
+ ) : chProgress >= 0.9 ? (
470
+ <Check className="h-3.5 w-3.5 text-status-completed" />
471
+ ) : chProgress > 0 ? (
472
+ <span className="text-xs text-muted-foreground">{chPct}%</span>
473
+ ) : null}
474
+ </span>
475
+ </div>
476
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
477
+ {ch.subtitle}
478
+ </p>
479
+ {chProgress > 0 && chProgress < 0.9 && (
480
+ <div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
481
+ <div
482
+ className="h-full bg-primary/40 rounded-full transition-all"
483
+ style={{ width: `${chPct}%` }}
484
+ />
485
+ </div>
486
+ )}
487
+ </button>
488
+ );
489
+ })}
490
+ </div>
491
+ </div>
492
+ ))}
493
+ </div>
494
+ ) : (
495
+ <div className="space-y-1">
496
+ {bookmarks.length === 0 ? (
497
+ <div className="text-center py-8">
498
+ <BookmarkIcon className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
499
+ <p className="text-sm text-muted-foreground">No bookmarks yet</p>
500
+ <p className="text-xs text-muted-foreground mt-1">
501
+ Use the bookmark button while reading to save your place
502
+ </p>
503
+ </div>
504
+ ) : (
505
+ bookmarks.map((bm) => (
506
+ <div
507
+ key={bm.id}
508
+ className="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-muted group"
509
+ >
510
+ <button
511
+ onClick={() => goToBookmark(bm)}
512
+ className="flex-1 text-left cursor-pointer"
513
+ >
514
+ <p className="text-sm font-medium">{bm.label}</p>
515
+ <p className="text-xs text-muted-foreground mt-0.5">
516
+ {new Date(bm.createdAt).toLocaleDateString(undefined, {
517
+ month: "short",
518
+ day: "numeric",
519
+ hour: "numeric",
520
+ minute: "2-digit",
521
+ })}
522
+ </p>
523
+ </button>
524
+ <button
525
+ onClick={() => removeBookmark(bm.id)}
526
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all cursor-pointer p-1"
527
+ title="Remove bookmark"
528
+ >
529
+ <BookmarkMinus className="h-3.5 w-3.5" />
530
+ </button>
531
+ </div>
532
+ ))
533
+ )}
534
+ </div>
535
+ )}
536
+ </div>
537
+ </SheetContent>
538
+ </Sheet>
539
+
540
+ <div className="hidden sm:block">
541
+ <p className="text-sm font-medium">
542
+ Chapter {currentChapter.number}: {currentChapter.title}
543
+ </p>
544
+ </div>
545
+ </div>
546
+
547
+ <div className="flex items-center gap-1">
548
+ <span className="text-xs text-muted-foreground mr-2 hidden sm:inline-flex items-center gap-1">
549
+ <Clock className="h-3 w-3" />
550
+ {currentChapter.readingTime} min
551
+ </span>
552
+
553
+ {/* Overall progress or path progress */}
554
+ {activePath ? (
555
+ <div className="hidden sm:flex mr-2">
556
+ <PathProgress pathId={activePath} progress={progress} />
557
+ </div>
558
+ ) : (
559
+ <div className="hidden sm:flex items-center gap-2 mr-2">
560
+ <div className="w-20 h-1.5 rounded-full bg-muted overflow-hidden">
561
+ <div
562
+ className="h-full bg-primary rounded-full transition-all"
563
+ style={{ width: `${overallProgress * 100}%` }}
564
+ />
565
+ </div>
566
+ <span className="text-xs text-muted-foreground">
567
+ {Math.round(overallProgress * 100)}%
568
+ </span>
569
+ </div>
570
+ )}
571
+
572
+ {/* Bookmark button */}
573
+ <Button
574
+ variant="ghost"
575
+ size="icon"
576
+ className="h-8 w-8"
577
+ onClick={addBookmark}
578
+ title="Bookmark this position"
579
+ >
580
+ {currentChapterBookmarks.length > 0 ? (
581
+ <BookmarkIcon className="h-4 w-4 fill-primary text-primary" />
582
+ ) : (
583
+ <BookmarkPlus className="h-4 w-4" />
584
+ )}
585
+ </Button>
586
+
587
+ {/* Settings */}
588
+ <Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
589
+ <SheetTrigger asChild>
590
+ <Button variant="ghost" size="icon" className="h-8 w-8">
591
+ <Settings2 className="h-4 w-4" />
592
+ </Button>
593
+ </SheetTrigger>
594
+ <SheetContent side="right" className="w-80">
595
+ <SheetHeader className="p-4">
596
+ <SheetTitle>Reading Settings</SheetTitle>
597
+ </SheetHeader>
598
+ <div className="px-6 pb-6 space-y-6">
599
+ {/* Font size */}
600
+ <div>
601
+ <label className="text-sm font-medium mb-2 block">
602
+ Font Size: {prefs.fontSize}px
603
+ </label>
604
+ <Slider
605
+ value={[prefs.fontSize]}
606
+ min={14}
607
+ max={22}
608
+ step={2}
609
+ onValueChange={([v]) => updatePrefs({ fontSize: v })}
610
+ />
611
+ <div className="flex justify-between text-xs text-muted-foreground mt-1">
612
+ <span>Small</span>
613
+ <span>Large</span>
614
+ </div>
615
+ </div>
616
+
617
+ {/* Line height */}
618
+ <div>
619
+ <label className="text-sm font-medium mb-2 block">
620
+ Line Height: {prefs.lineHeight.toFixed(2)}
621
+ </label>
622
+ <Slider
623
+ value={[prefs.lineHeight * 100]}
624
+ min={150}
625
+ max={200}
626
+ step={5}
627
+ onValueChange={([v]) => updatePrefs({ lineHeight: v / 100 })}
628
+ />
629
+ <div className="flex justify-between text-xs text-muted-foreground mt-1">
630
+ <span>Compact</span>
631
+ <span>Relaxed</span>
632
+ </div>
633
+ </div>
634
+
635
+ {/* Font family */}
636
+ <div>
637
+ <label className="text-sm font-medium mb-3 block">Font</label>
638
+ <div className="grid grid-cols-3 gap-2">
639
+ {(["sans", "serif", "mono"] as const).map((f) => (
640
+ <button
641
+ key={f}
642
+ onClick={() => updatePrefs({ fontFamily: f })}
643
+ className={cn(
644
+ "px-3 py-2 rounded-lg border text-sm capitalize transition-colors cursor-pointer",
645
+ prefs.fontFamily === f
646
+ ? "border-primary bg-primary/10 text-primary"
647
+ : "border-border hover:bg-muted"
648
+ )}
649
+ >
650
+ <span className={f === "serif" ? "font-serif" : f === "mono" ? "font-mono" : "font-sans"}>
651
+ {f}
652
+ </span>
653
+ </button>
654
+ ))}
655
+ </div>
656
+ </div>
657
+
658
+ {/* Theme */}
659
+ <div>
660
+ <label className="text-sm font-medium mb-3 block">Reader Theme</label>
661
+ <div className="grid grid-cols-3 gap-2">
662
+ {(["light", "sepia", "dark"] as const).map((t) => (
663
+ <button
664
+ key={t}
665
+ onClick={() => updatePrefs({ theme: t })}
666
+ className={cn(
667
+ "px-3 py-2 rounded-lg border text-sm capitalize transition-colors cursor-pointer",
668
+ `book-theme-preview-${t}`,
669
+ prefs.theme === t
670
+ ? "border-primary bg-primary/10 text-primary"
671
+ : "border-border hover:bg-muted"
672
+ )}
673
+ >
674
+ {t}
675
+ </button>
676
+ ))}
677
+ </div>
678
+ </div>
679
+ </div>
680
+ </SheetContent>
681
+ </Sheet>
682
+ </div>
683
+ </header>
684
+
685
+ {/* Reading area */}
686
+ <div ref={contentRef} className="flex-1 overflow-y-auto">
687
+ <article
688
+ className={cn("mx-auto max-w-2xl px-6 py-10 sm:px-8 sm:py-14", fontFamilyClass)}
689
+ style={{ fontSize: `${prefs.fontSize}px`, lineHeight: prefs.lineHeight }}
690
+ >
691
+ {/* Chapter header */}
692
+ <header className="mb-12">
693
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
694
+ Part {currentChapter.part.number}: {currentChapter.part.title}
695
+ </p>
696
+ <h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-3">
697
+ Chapter {currentChapter.number}: {currentChapter.title}
698
+ </h1>
699
+ <p className="text-lg text-muted-foreground">{currentChapter.subtitle}</p>
700
+ <div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
701
+ <span className="inline-flex items-center gap-1">
702
+ <Clock className="h-3.5 w-3.5" />
703
+ {currentChapter.readingTime} min read
704
+ </span>
705
+ <span>
706
+ {currentChapter.sections.length} section{currentChapter.sections.length !== 1 && "s"}
707
+ </span>
708
+ {(progress[currentChapter.id]?.progress ?? 0) > 0 && (
709
+ <span className="inline-flex items-center gap-1">
710
+ {Math.round((progress[currentChapter.id]?.progress ?? 0) * 100)}% read
711
+ </span>
712
+ )}
713
+ </div>
714
+
715
+ {/* Chapter generation bar */}
716
+ <ChapterGenerationBar
717
+ chapterId={currentChapter.id}
718
+ chapterTitle={currentChapter.title}
719
+ chapterNumber={currentChapter.number}
720
+ hasContent={currentChapter.sections.length > 0}
721
+ onComplete={() => router.refresh()}
722
+ />
723
+
724
+ <hr className="mt-8 border-border/50" />
725
+ </header>
726
+
727
+ {/* Sections or empty state */}
728
+ {currentChapter.sections.length > 0 ? (
729
+ currentChapter.sections.map((section) => (
730
+ <section key={section.id} id={section.id} className="mb-12">
731
+ <h2 className="text-2xl font-semibold tracking-tight mb-6">
732
+ {section.title}
733
+ </h2>
734
+ <div className="space-y-2">
735
+ {section.content.map((block, i) => (
736
+ <ContentBlockRenderer key={i} block={block} />
737
+ ))}
738
+ </div>
739
+ </section>
740
+ ))
741
+ ) : (
742
+ <div className="text-center py-16 space-y-4">
743
+ <Sparkles className="h-12 w-12 text-muted-foreground/30 mx-auto" />
744
+ <h3 className="text-lg font-medium">This chapter hasn&apos;t been written yet</h3>
745
+ <p className="text-muted-foreground text-sm max-w-md mx-auto">
746
+ Generate it from the source material using the button above.
747
+ </p>
748
+ </div>
749
+ )}
750
+
751
+ {/* Chapter footer */}
752
+ <footer className="mt-12 pt-6 border-t border-border/30 text-xs text-muted-foreground/60">
753
+ <span>
754
+ Chapter {currentChapter.number} of {CHAPTERS.length}
755
+ </span>
756
+ </footer>
757
+
758
+ {/* Try It Now — related Playbook docs */}
759
+ {currentChapter.relatedDocs && currentChapter.relatedDocs.length > 0 && (
760
+ <TryItNow
761
+ relatedDocs={currentChapter.relatedDocs}
762
+ relatedJourney={currentChapter.relatedJourney}
763
+ />
764
+ )}
765
+
766
+ {/* Chapter navigation */}
767
+ <nav className="flex items-center justify-between border-t border-border/50 pt-8 mt-16">
768
+ {prevChapter ? (
769
+ <button
770
+ onClick={() => goToChapter(prevChapter)}
771
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-lg px-3 py-2 -mx-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
772
+ >
773
+ <ChevronLeft className="h-4 w-4" />
774
+ <div className="text-left">
775
+ <p className="text-xs text-muted-foreground">Previous</p>
776
+ <p className="font-medium">Ch. {prevChapter.number}: {prevChapter.title}</p>
777
+ </div>
778
+ </button>
779
+ ) : (
780
+ <div />
781
+ )}
782
+ {nextChapter ? (
783
+ <button
784
+ onClick={() => goToChapter(nextChapter)}
785
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-lg px-3 py-2 -mx-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
786
+ >
787
+ <div className="text-right">
788
+ <p className="text-xs text-muted-foreground">Next</p>
789
+ <p className="font-medium">Ch. {nextChapter.number}: {nextChapter.title}</p>
790
+ </div>
791
+ <ChevronRight className="h-4 w-4" />
792
+ </button>
793
+ ) : (
794
+ <div />
795
+ )}
796
+ </nav>
797
+ </article>
798
+ </div>
799
+ </div>
800
+ );
801
+ }