@carlonicora/nextjs-jsonapi 3.12.0 → 3.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.
Files changed (143) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.js +401 -401
  16. package/dist/billing/index.mjs +3 -3
  17. package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
  18. package/dist/chunk-IB7DTXWG.js.map +1 -0
  19. package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
  20. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  21. package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
  22. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  23. package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
  24. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  25. package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
  26. package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
  27. package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
  28. package/dist/client/index.d.mts +2 -2
  29. package/dist/client/index.d.ts +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.d.mts +258 -7
  33. package/dist/components/index.d.ts +258 -7
  34. package/dist/components/index.js +30 -4
  35. package/dist/components/index.js.map +1 -1
  36. package/dist/components/index.mjs +29 -3
  37. package/dist/contexts/index.d.mts +17 -5
  38. package/dist/contexts/index.d.ts +17 -5
  39. package/dist/contexts/index.js +4 -4
  40. package/dist/contexts/index.mjs +3 -3
  41. package/dist/core/index.d.mts +491 -11
  42. package/dist/core/index.d.ts +491 -11
  43. package/dist/core/index.js +38 -2
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/index.mjs +37 -1
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +101 -101
  49. package/dist/features/tokenusage/index.mjs +3 -3
  50. package/dist/index.d.mts +7 -7
  51. package/dist/index.d.ts +7 -7
  52. package/dist/index.js +39 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +38 -2
  55. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  56. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  57. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  58. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  59. package/dist/server/index.d.mts +3 -3
  60. package/dist/server/index.d.ts +3 -3
  61. package/dist/server/index.js +3 -3
  62. package/dist/server/index.mjs +1 -1
  63. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  64. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  65. package/package.json +1 -1
  66. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  67. package/src/components/index.ts +1 -0
  68. package/src/core/index.ts +5 -0
  69. package/src/core/registry/ModuleRegistry.ts +4 -0
  70. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  71. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  72. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  73. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  74. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  75. package/src/features/assistant/data/Assistant.ts +2 -0
  76. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  77. package/src/features/assistant/data/AssistantService.ts +4 -0
  78. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  79. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  80. package/src/features/content/data/content.interface.ts +1 -1
  81. package/src/features/handbook/HandbookPageModule.ts +41 -0
  82. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  83. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  84. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  85. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  86. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  87. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  88. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  89. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  90. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  91. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  92. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  93. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  94. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  95. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  96. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  97. package/src/features/handbook/components/index.ts +12 -0
  98. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  99. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  100. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  101. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  102. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  103. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  104. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  105. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  106. package/src/features/handbook/data/HandbookPage.ts +99 -0
  107. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  108. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  109. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  110. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  111. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  112. package/src/features/handbook/data/HandbookSection.ts +73 -0
  113. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  114. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  115. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  116. package/src/features/handbook/data/HandbookThread.ts +114 -0
  117. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  118. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  119. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  120. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  121. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  122. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  123. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  124. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  125. package/src/features/handbook/data/index.ts +16 -0
  126. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  127. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  128. package/src/features/handbook/i18n-keys.ts +46 -0
  129. package/src/features/handbook/index.ts +7 -0
  130. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  131. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  132. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  133. package/src/features/how-to/components/index.ts +1 -0
  134. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  135. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  136. package/src/features/how-to/data/HowToService.ts +13 -0
  137. package/dist/chunk-JEGL7KHK.mjs.map +0 -1
  138. package/dist/chunk-L7DZSTFU.js.map +0 -1
  139. package/dist/chunk-OKGGYCO5.js.map +0 -1
  140. package/dist/chunk-Q2BREXH3.mjs.map +0 -1
  141. /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  142. /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  143. /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { MessagesSquareIcon, Sparkles } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import Link from "next/link";
6
+ import { useCallback, useState } from "react";
7
+
8
+ import { SectionHeader } from "../../../../components/typography";
9
+ import { Modules } from "../../../../core";
10
+ import { usePageUrlGenerator } from "../../../../hooks";
11
+ import {
12
+ Button,
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger,
17
+ Sheet,
18
+ SheetContent,
19
+ SheetHeader,
20
+ SheetTitle,
21
+ SheetTrigger,
22
+ Switch,
23
+ } from "../../../../shadcnui";
24
+ import { AssistantComposer } from "../../../assistant/components/parts/AssistantComposer";
25
+ import { AssistantThread } from "../../../assistant/components/parts/AssistantThread";
26
+ import { useHandbookAsk } from "../../hooks/useHandbookAsk";
27
+
28
+ /**
29
+ * The ask, reachable from every handbook surface.
30
+ *
31
+ * It is the same conversation as `/administration/handbook/chat`, not a second
32
+ * one: both mount `useHandbookAsk`, so the threads listed here are the threads
33
+ * listed there and a question asked in the sheet is readable on the route. Only
34
+ * two things differ.
35
+ *
36
+ * 1. No url sync. A sheet floats over the page a reader is on; rewriting the
37
+ * address under it would send them somewhere else the moment they close it.
38
+ * `syncUrl: false` is exactly that difference.
39
+ * 2. No thread sidebar. The full list belongs to the route — the sheet carries
40
+ * the five most recent threads in a menu and a link to the rest, which is
41
+ * what a side panel has room for.
42
+ *
43
+ * On the reader the sheet also knows which page is open, and offers to confine
44
+ * the answer to it. The chip is off by default: a reader asking from inside a
45
+ * page usually still wants the whole manual to answer, and the narrow search is
46
+ * the deliberate choice.
47
+ */
48
+ export function HandbookAskSheet({ handbookPageId }: HandbookAskSheetProps = {}) {
49
+ const t = useTranslations();
50
+ const generateUrl = usePageUrlGenerator();
51
+
52
+ const { threads, thread, messages, sending, ask, selectThread } = useHandbookAsk({ syncUrl: false });
53
+ const [scoped, setScoped] = useState(false);
54
+
55
+ const chatUrl = `${generateUrl({ page: Modules.HandbookPage })}/chat`;
56
+ const showThread = !!thread || sending || messages.length > 0;
57
+
58
+ const send = useCallback(
59
+ async (question: string) => {
60
+ await ask(question, { handbookPageId: scoped ? handbookPageId : undefined });
61
+ },
62
+ [ask, handbookPageId, scoped],
63
+ );
64
+
65
+ return (
66
+ <Sheet>
67
+ {/* `render`, never a nested <Button>: the trigger draws its own <button>,
68
+ and wrapping one inside it is invalid HTML. */}
69
+ <SheetTrigger render={<Button variant="outline" />}>
70
+ <MessagesSquareIcon className="me-1 h-4 w-4" />
71
+ {t("handbook.ask.open")}
72
+ </SheetTrigger>
73
+ <SheetContent side="end" className="flex w-full flex-col sm:max-w-lg">
74
+ {/* pe-10 clears SheetContent's own close button, which sits at the
75
+ inline end of the same row. */}
76
+ <SheetHeader className="flex-row items-center gap-2 pe-10">
77
+ <SheetTitle className="flex-1">{t("handbook.chat.title")}</SheetTitle>
78
+
79
+ <DropdownMenu>
80
+ <DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>
81
+ {t("handbook.ask.recent")}
82
+ </DropdownMenuTrigger>
83
+ <DropdownMenuContent align="end">
84
+ {threads.slice(0, 5).map((item) => (
85
+ <DropdownMenuItem key={item.id} onClick={() => void selectThread(item.id)}>
86
+ {item.title}
87
+ </DropdownMenuItem>
88
+ ))}
89
+ <DropdownMenuItem>
90
+ <Link href={chatUrl} className="text-primary w-full font-medium">
91
+ {t("handbook.ask.all")}
92
+ </Link>
93
+ </DropdownMenuItem>
94
+ </DropdownMenuContent>
95
+ </DropdownMenu>
96
+
97
+ {thread && (
98
+ <Link href={`${chatUrl}/${thread.id}`} className="text-primary font-medium">
99
+ {t("handbook.ask.expand")}
100
+ </Link>
101
+ )}
102
+ </SheetHeader>
103
+
104
+ <div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
105
+ {showThread ? (
106
+ <AssistantThread
107
+ messages={messages}
108
+ sending={sending}
109
+ status={t("handbook.chat.pending")}
110
+ onSelectFollowUp={send}
111
+ />
112
+ ) : (
113
+ <div className="flex flex-1 items-center justify-center p-6">
114
+ <div className="flex max-w-sm flex-col items-center text-center">
115
+ <span className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-blue-400 to-violet-500 text-white">
116
+ <Sparkles className="h-5 w-5" />
117
+ </span>
118
+ <SectionHeader>{t("handbook.chat.empty_state.title")}</SectionHeader>
119
+ <p className="text-muted-foreground mt-1 text-sm">{t("handbook.chat.empty_state.subtitle")}</p>
120
+ </div>
121
+ </div>
122
+ )}
123
+
124
+ {handbookPageId && (
125
+ <div className="flex items-center gap-2 border-t px-4 pt-3">
126
+ <Switch checked={scoped} onCheckedChange={setScoped} aria-label={t("handbook.ask.thisPageOnly")} />
127
+ <span className="text-muted-foreground text-xs">{t("handbook.ask.thisPageOnly")}</span>
128
+ </div>
129
+ )}
130
+
131
+ <AssistantComposer
132
+ onSend={send}
133
+ disabled={sending}
134
+ placeholder={t("handbook.chat.placeholder")}
135
+ sendLabel={t("handbook.chat.ask")}
136
+ />
137
+ </div>
138
+ </SheetContent>
139
+ </Sheet>
140
+ );
141
+ }
142
+
143
+ type HandbookAskSheetProps = {
144
+ /**
145
+ * The page the reader has open. Present only on the reader; when set, the
146
+ * sheet offers to confine the next question to that page.
147
+ */
148
+ handbookPageId?: string;
149
+ };
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { RoundPageContainer } from "../../../../components";
6
+ import { Modules } from "../../../../core";
7
+ import { HandbookProvider } from "../../contexts/HandbookContext";
8
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
9
+ import { HandbookPageService } from "../../data/HandbookPageService";
10
+ import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
11
+ import { HandbookSectionService } from "../../data/HandbookSectionService";
12
+ import { HandbookPageDetails } from "../details/HandbookPageDetails";
13
+ import { HandbookPageNeighbours } from "../details/HandbookPageNeighbours";
14
+ import { HandbookPageToc } from "../details/HandbookPageToc";
15
+ import { HandbookPageNavigator } from "../parts/HandbookPageNavigator";
16
+ import { HandbookAskSheet } from "./HandbookAskSheet";
17
+
18
+ type HandbookPageContainerProps = {
19
+ handbookPageId: string;
20
+ };
21
+
22
+ /** The element the heading index reads its headings out of. */
23
+ const CONTENT_CONTAINER_ID = "handbook-page-content";
24
+
25
+ /**
26
+ * Fetches one page client-side from its id.
27
+ *
28
+ * The sibling how-to detail uses a server page that dehydrates into a provider.
29
+ * This one does not, for the same reason the ask surface does not: a handbook
30
+ * page is read-only and never edited, so there is no shared mutable state for a
31
+ * provider to hold, and an id string crosses the server/client boundary without
32
+ * any dehydrate/rehydrate ceremony.
33
+ *
34
+ * Three loads, deliberately separate: the page itself, the whole ordered page
35
+ * list (the navigator and the prev/next pair need it) and the sections. The
36
+ * section load has its own `try` — a failing sections endpoint degrades the
37
+ * navigator to raw keys, and must never take the page being read down with it.
38
+ */
39
+ export default function HandbookPageContainer({ handbookPageId }: HandbookPageContainerProps) {
40
+ const t = useTranslations();
41
+ const [handbookPage, setHandbookPage] = useState<HandbookPageInterface | undefined>(undefined);
42
+ const [pages, setPages] = useState<HandbookPageInterface[]>([]);
43
+ const [sections, setSections] = useState<HandbookSectionInterface[]>([]);
44
+ const [isLoading, setIsLoading] = useState(true);
45
+
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+
49
+ const load = async () => {
50
+ setIsLoading(true);
51
+ try {
52
+ const page = await HandbookPageService.findOne({ id: handbookPageId });
53
+ if (!cancelled) setHandbookPage(page);
54
+ } finally {
55
+ if (!cancelled) setIsLoading(false);
56
+ }
57
+ };
58
+
59
+ void load();
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [handbookPageId]);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+
69
+ const loadPages = async () => {
70
+ const allPages = await HandbookPageService.findMany({ fetchAll: true });
71
+ if (!cancelled) setPages(allPages);
72
+ };
73
+
74
+ const loadSections = async () => {
75
+ try {
76
+ const allSections = await HandbookSectionService.findMany();
77
+ if (!cancelled) setSections(allSections);
78
+ } catch {
79
+ if (!cancelled) setSections([]);
80
+ }
81
+ };
82
+
83
+ void loadPages();
84
+ void loadSections();
85
+
86
+ return () => {
87
+ cancelled = true;
88
+ };
89
+ }, []);
90
+
91
+ const section = useMemo(
92
+ () => sections.find((candidate) => candidate.key === handbookPage?.section),
93
+ [sections, handbookPage],
94
+ );
95
+
96
+ return (
97
+ <HandbookProvider
98
+ page={handbookPage}
99
+ section={section}
100
+ functions={handbookPage ? <HandbookAskSheet handbookPageId={handbookPage.id} /> : undefined}
101
+ >
102
+ {/*
103
+ `fullWidth` on the READER, unlike the contents page.
104
+
105
+ The container's `max-w-6xl` exists to hold a reading measure, and the
106
+ two rails are not reading — cramming navigator, prose and heading index
107
+ into that one column squeezes the prose to a third of it. So the reader
108
+ takes the full width and caps only its centre column, which is where the
109
+ measure belongs.
110
+ */}
111
+ <RoundPageContainer module={Modules.HandbookPage} fullWidth forceHeader>
112
+ {isLoading ? null : handbookPage ? (
113
+ /*
114
+ `items-start` is what makes `sticky` work on the two side columns:
115
+ a grid item stretches to the row's height by default, so a sticky
116
+ child inside it has nowhere to travel and the whole page scrolls
117
+ instead. With `items-start` each column is only as tall as its
118
+ content, and the rails hold their place while the page moves.
119
+
120
+ Each rail wrapper owns the scrolling — the navigator carries no
121
+ `max-h` of its own, so there is exactly one scroll container per
122
+ rail rather than two nested ones.
123
+ */
124
+ <div className="grid items-start gap-8 p-4 lg:grid-cols-[240px_minmax(0,1fr)_240px]">
125
+ <div className="sticky top-4 hidden max-h-[calc(100svh-8rem)] overflow-y-auto lg:block">
126
+ <HandbookPageNavigator pages={pages} sections={sections} currentId={handbookPage.id} />
127
+ </div>
128
+ {/* The measure lives HERE, on the prose, not on the whole page. */}
129
+ <div id={CONTENT_CONTAINER_ID} className="mx-auto w-full max-w-3xl min-w-0">
130
+ <HandbookPageDetails handbookPage={handbookPage} />
131
+ <HandbookPageNeighbours pages={pages} currentId={handbookPage.id} />
132
+ </div>
133
+ <div className="sticky top-4 hidden max-h-[calc(100svh-8rem)] overflow-y-auto lg:block">
134
+ <HandbookPageToc containerId={CONTENT_CONTAINER_ID} />
135
+ </div>
136
+ </div>
137
+ ) : (
138
+ <span className="text-muted-foreground text-sm">{t("handbook.notFound")}</span>
139
+ )}
140
+ </RoundPageContainer>
141
+ </HandbookProvider>
142
+ );
143
+ }
@@ -0,0 +1,268 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
6
+ import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
7
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
8
+ import { HandbookThread } from "../../../data/HandbookThread";
9
+ import { HandbookThreadMessage } from "../../../data/HandbookThreadMessage";
10
+ import { HandbookThreadMessageService } from "../../../data/HandbookThreadMessageService";
11
+ import { HandbookThreadService } from "../../../data/HandbookThreadService";
12
+ import { HandbookAskContainer } from "../HandbookAskContainer";
13
+
14
+ vi.mock("../../../data/HandbookThreadService", () => ({
15
+ HandbookThreadService: {
16
+ findMany: vi.fn(),
17
+ findOne: vi.fn(),
18
+ create: vi.fn(),
19
+ rename: vi.fn(),
20
+ delete: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ vi.mock("../../../data/HandbookThreadMessageService", () => ({
25
+ HandbookThreadMessageService: { ask: vi.fn() },
26
+ }));
27
+
28
+ // RoundPageContainer is page chrome — header, details panel, url rewriting.
29
+ // None of it is under test here, and all of it needs providers this spec has no
30
+ // reason to mount. The stub still renders `details`, because that is where the
31
+ // thread list lives in this layout (copied from AssistantPageContainer).
32
+ vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
33
+ RoundPageContainer: ({ children, details }: { children?: React.ReactNode; details?: React.ReactNode }) => (
34
+ <div>
35
+ {details}
36
+ {children}
37
+ </div>
38
+ ),
39
+ }));
40
+
41
+ // RoundPageContainer reads Modules.HandbookPage; the handbook models resolve
42
+ // Modules.HandbookThread / Modules.HandbookThreadMessage lazily through
43
+ // ModuleRegistry. Registering is the package's spec convention (see
44
+ // HandbookPage.spec.ts) and is preferred to mocking the core barrel, which the
45
+ // reused assistant components also import from.
46
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
47
+ try {
48
+ ModuleRegistry.get(key as any);
49
+ } catch {
50
+ ModuleRegistry.register(key, module);
51
+ }
52
+ };
53
+
54
+ beforeAll(() => {
55
+ registerIfAbsent("HandbookPage", { name: "handbookpages" } as any);
56
+ const threadModule = { name: "handbookthreads", model: HandbookThread } as any;
57
+ const messageModule = { name: "handbookthreadmessages", model: HandbookThreadMessage } as any;
58
+ registerIfAbsent("HandbookThread", threadModule);
59
+ registerIfAbsent("HandbookThreadMessage", messageModule);
60
+ // `_readIncluded` and `rehydrate()` resolve the CONSTRUCTOR through
61
+ // DataClassRegistry, which the app's Bootstrapper fills from the same module
62
+ // definitions — registering only in ModuleRegistry leaves side-loaded
63
+ // messages unrehydratable.
64
+ DataClassRegistry.registerObjectClass(threadModule, HandbookThread as any);
65
+ DataClassRegistry.registerObjectClass(messageModule, HandbookThreadMessage as any);
66
+ });
67
+
68
+ function buildThread(params: {
69
+ id: string;
70
+ title: string;
71
+ updatedAt?: string;
72
+ messages?: { id: string; role: "user" | "assistant"; content: string; position: number; sources?: string[] }[];
73
+ }): HandbookThread {
74
+ const messages = params.messages ?? [];
75
+ return new HandbookThread().rehydrate({
76
+ jsonApi: {
77
+ id: params.id,
78
+ type: "handbookthreads",
79
+ attributes: { title: params.title },
80
+ meta: { updatedAt: params.updatedAt ?? new Date().toISOString() },
81
+ relationships: {
82
+ messages: { data: messages.map((m) => ({ id: m.id, type: "handbookthreadmessages" })) },
83
+ },
84
+ },
85
+ included: messages.map((m) => ({
86
+ id: m.id,
87
+ type: "handbookthreadmessages",
88
+ attributes: { role: m.role, content: m.content, position: m.position, sources: m.sources ?? [] },
89
+ })),
90
+ } as any);
91
+ }
92
+
93
+ /** The send control belongs to the shared AssistantComposer, not to this feature. */
94
+ const sendButton = () => screen.getByRole("button", { name: /handbook\.chat\.ask/i });
95
+
96
+ describe("HandbookAskContainer", () => {
97
+ beforeEach(() => {
98
+ vi.mocked(HandbookThreadService.findMany).mockReset().mockResolvedValue([]);
99
+ vi.mocked(HandbookThreadService.findOne).mockReset();
100
+ vi.mocked(HandbookThreadService.create).mockReset();
101
+ vi.mocked(HandbookThreadService.delete).mockReset().mockResolvedValue(undefined);
102
+ vi.mocked(HandbookThreadService.rename).mockReset().mockResolvedValue(undefined);
103
+ vi.mocked(HandbookThreadMessageService.ask).mockReset().mockResolvedValue(undefined);
104
+ });
105
+
106
+ it("reuses the shared composer and sidebar but never the assistant's copy", async () => {
107
+ render(<HandbookAskContainer />);
108
+
109
+ expect(screen.getByPlaceholderText("handbook.chat.placeholder")).toBeInTheDocument();
110
+ expect(screen.queryByPlaceholderText("features.assistant.composer_placeholder")).not.toBeInTheDocument();
111
+ expect(sendButton()).toBeDisabled();
112
+ expect(screen.getByRole("button", { name: /handbook\.chat\.new/ })).toBeInTheDocument();
113
+ await waitFor(() => expect(screen.getByText("handbook.chat.empty_sidebar")).toBeInTheDocument());
114
+ expect(screen.queryByText("features.assistant.empty_sidebar")).not.toBeInTheDocument();
115
+ });
116
+
117
+ it("shows the handbook empty state until a thread exists", async () => {
118
+ render(<HandbookAskContainer />);
119
+
120
+ expect(screen.getByText("handbook.chat.empty_state.title")).toBeInTheDocument();
121
+ expect(screen.getByText("handbook.chat.empty_state.subtitle")).toBeInTheDocument();
122
+ await waitFor(() => expect(HandbookThreadService.findMany).toHaveBeenCalled());
123
+ });
124
+
125
+ it("lists the previous threads in the sidebar", async () => {
126
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
127
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
128
+ buildThread({ id: "t2", title: "Porte del worker" }),
129
+ ]);
130
+
131
+ render(<HandbookAskContainer />);
132
+
133
+ await waitFor(() => expect(screen.getByRole("button", { name: "Filtri di azienda" })).toBeInTheDocument());
134
+ expect(screen.getByRole("button", { name: "Porte del worker" })).toBeInTheDocument();
135
+ });
136
+
137
+ it("loads the messages of the thread picked from the sidebar", async () => {
138
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
139
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
140
+ ]);
141
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
142
+ buildThread({
143
+ id: "t1",
144
+ title: "Filtri di azienda",
145
+ messages: [
146
+ { id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
147
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
148
+ ],
149
+ }),
150
+ );
151
+
152
+ render(<HandbookAskContainer />);
153
+ await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
154
+
155
+ await waitFor(() => expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" }));
156
+ expect(await screen.findByText("Come funziona buildDefaultMatch?")).toBeInTheDocument();
157
+ expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
158
+ });
159
+
160
+ it("asks the trimmed question, creates a thread and shows its title", async () => {
161
+ vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Filtri di azienda" }));
162
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
163
+ buildThread({
164
+ id: "t9",
165
+ title: "Filtri di azienda",
166
+ messages: [
167
+ { id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
168
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
169
+ ],
170
+ }),
171
+ );
172
+
173
+ render(<HandbookAskContainer />);
174
+ await userEvent.type(screen.getByRole("textbox"), " Come funziona buildDefaultMatch? ");
175
+ await userEvent.click(sendButton());
176
+
177
+ await waitFor(() =>
178
+ expect(HandbookThreadService.create).toHaveBeenCalledWith(
179
+ expect.objectContaining({ question: "Come funziona buildDefaultMatch?" }),
180
+ ),
181
+ );
182
+
183
+ // The title only exists server-side until the thread is read back, so its
184
+ // appearance in the sidebar row is the proof the transcript was reloaded.
185
+ await waitFor(() => expect(screen.getAllByText("Filtri di azienda").length).toBeGreaterThan(0));
186
+ expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
187
+
188
+ // There is exactly ONE header on this page — RoundPageContainer's. The
189
+ // assistant's per-thread header is deliberately not rendered, so neither
190
+ // its rename nor its delete control may appear.
191
+ expect(screen.queryByRole("button", { name: "features.assistant.rename" })).toBeNull();
192
+ expect(screen.queryByRole("button", { name: "features.assistant.delete" })).toBeNull();
193
+ });
194
+
195
+ it("appends to the open thread instead of creating a second one", async () => {
196
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
197
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
198
+ ]);
199
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
200
+ buildThread({
201
+ id: "t1",
202
+ title: "Filtri di azienda",
203
+ messages: [{ id: "m1", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 }],
204
+ }),
205
+ );
206
+
207
+ render(<HandbookAskContainer />);
208
+ await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
209
+ await waitFor(() => expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument());
210
+
211
+ await userEvent.type(screen.getByRole("textbox"), "E i cursori?");
212
+ await userEvent.click(sendButton());
213
+
214
+ await waitFor(() =>
215
+ expect(HandbookThreadMessageService.ask).toHaveBeenCalledWith(
216
+ expect.objectContaining({ threadId: "t1", question: "E i cursori?" }),
217
+ ),
218
+ );
219
+ expect(HandbookThreadService.create).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it("renders the cited sources inside the answer", async () => {
223
+ vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Fonti" }));
224
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
225
+ buildThread({
226
+ id: "t9",
227
+ title: "Fonti",
228
+ messages: [
229
+ {
230
+ id: "m2",
231
+ role: "assistant",
232
+ content: "Inietta il filtro di azienda.",
233
+ position: 1,
234
+ sources: ["03-backend/repositories.md", "02-framework/security.md"],
235
+ },
236
+ ],
237
+ }),
238
+ );
239
+
240
+ render(<HandbookAskContainer />);
241
+ await userEvent.type(screen.getByRole("textbox"), "Perché?");
242
+ await userEvent.click(sendButton());
243
+
244
+ await waitFor(() => expect(screen.getByText("03-backend/repositories.md")).toBeInTheDocument());
245
+ expect(screen.getByText("02-framework/security.md")).toBeInTheDocument();
246
+ expect(screen.getByText("handbook.chat.sources")).toBeInTheDocument();
247
+ });
248
+
249
+ it("surfaces a failure as an assistant message rather than an error pane", async () => {
250
+ vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("AI is not configured"));
251
+
252
+ render(<HandbookAskContainer />);
253
+ await userEvent.type(screen.getByRole("textbox"), "Perché?");
254
+ await userEvent.click(sendButton());
255
+
256
+ await waitFor(() => expect(screen.getByText("handbook.chat.failed")).toBeInTheDocument());
257
+ });
258
+
259
+ it("keeps the asked question in the transcript when the answer fails", async () => {
260
+ vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("boom"));
261
+
262
+ render(<HandbookAskContainer />);
263
+ await userEvent.type(screen.getByRole("textbox"), "Che porte usa il worker?");
264
+ await userEvent.click(sendButton());
265
+
266
+ await waitFor(() => expect(screen.getByText("Che porte usa il worker?")).toBeInTheDocument());
267
+ });
268
+ });