@carlonicora/nextjs-jsonapi 6.9.0 → 6.10.1

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 (51) hide show
  1. package/dist/{BlockNoteEditor-WN73IHXR.mjs → BlockNoteEditor-GK3DRTOP.mjs} +49 -15
  2. package/dist/BlockNoteEditor-GK3DRTOP.mjs.map +1 -0
  3. package/dist/{BlockNoteEditor-XXH3VBZ7.js → BlockNoteEditor-YOKJOVV3.js} +64 -30
  4. package/dist/BlockNoteEditor-YOKJOVV3.js.map +1 -0
  5. package/dist/billing/index.js +436 -435
  6. package/dist/billing/index.js.map +1 -1
  7. package/dist/billing/index.mjs +8 -7
  8. package/dist/billing/index.mjs.map +1 -1
  9. package/dist/{chunk-ZCM4JLM7.js → chunk-4UFMLTL2.js} +73 -71
  10. package/dist/chunk-4UFMLTL2.js.map +1 -0
  11. package/dist/{chunk-Q4XLE3QO.mjs → chunk-THZAK7S3.mjs} +9 -7
  12. package/dist/{chunk-Q4XLE3QO.mjs.map → chunk-THZAK7S3.mjs.map} +1 -1
  13. package/dist/client/index.js +2 -2
  14. package/dist/client/index.mjs +1 -1
  15. package/dist/components/index.d.mts +12 -5
  16. package/dist/components/index.d.ts +12 -5
  17. package/dist/components/index.js +2 -2
  18. package/dist/components/index.mjs +1 -1
  19. package/dist/contexts/index.js +2 -2
  20. package/dist/contexts/index.mjs +1 -1
  21. package/dist/email-automations/index.js +58 -58
  22. package/dist/email-automations/index.mjs +1 -1
  23. package/dist/features/analytics/index.js +87 -87
  24. package/dist/features/analytics/index.mjs +1 -1
  25. package/dist/features/blog/index.js +93 -93
  26. package/dist/features/blog/index.mjs +1 -1
  27. package/dist/features/help/index.js +31 -31
  28. package/dist/features/help/index.mjs +1 -1
  29. package/dist/features/tokenusage/index.js +77 -77
  30. package/dist/features/tokenusage/index.mjs +1 -1
  31. package/messages/en.json +4 -2
  32. package/messages/it.json +4 -2
  33. package/package.json +1 -1
  34. package/src/components/editors/BlockNoteEditor.tsx +69 -14
  35. package/src/components/editors/__tests__/BlockNoteEditor.ai.spec.tsx +295 -0
  36. package/src/features/assistant/components/containers/AssistantPageContainer.tsx +20 -9
  37. package/src/features/assistant/components/containers/__tests__/AssistantPageContainer.spec.tsx +116 -0
  38. package/src/features/assistant/components/parts/AssistantThread.tsx +2 -1
  39. package/src/features/assistant-message/components/MessageItem.tsx +1 -1
  40. package/src/features/assistant-message/components/MessageList.tsx +1 -1
  41. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +1 -1
  42. package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +1 -1
  43. package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +17 -1
  44. package/src/features/assistant-message/components/parts/__tests__/SuggestedQuestionsTab.spec.tsx +22 -0
  45. package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +5 -3
  46. package/src/features/billing/i18n-keys.ts +2 -0
  47. package/src/features/billing/stripe-subscription/components/widgets/ProrationPreview.tsx +1 -2
  48. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +16 -5
  49. package/dist/BlockNoteEditor-WN73IHXR.mjs.map +0 -1
  50. package/dist/BlockNoteEditor-XXH3VBZ7.js.map +0 -1
  51. package/dist/chunk-ZCM4JLM7.js.map +0 -1
@@ -41,7 +41,7 @@ import {
41
41
  TooltipContent,
42
42
  TooltipTrigger,
43
43
  usePageUrlGenerator
44
- } from "../../chunk-Q4XLE3QO.mjs";
44
+ } from "../../chunk-THZAK7S3.mjs";
45
45
  import "../../chunk-AWLTSKMM.mjs";
46
46
  import "../../chunk-PW42LRSL.mjs";
47
47
  import {
package/messages/en.json CHANGED
@@ -782,7 +782,7 @@
782
782
  },
783
783
  "proration": {
784
784
  "net_due_today": "Net Due Today",
785
- "next_invoice": "Next invoice on {date} for {amount}",
785
+ "next_invoice": "Charged today: {amount}",
786
786
  "title": "Proration Breakdown"
787
787
  },
788
788
  "subscription": {
@@ -859,6 +859,7 @@
859
859
  "amount_off": "{amount} off",
860
860
  "card_on_file": "{brand} ending in {last4}",
861
861
  "charge_now": "Amount to charge now:",
862
+ "credit_now": "Credit to your account:",
862
863
  "confirm_change": "Confirm Plan Change",
863
864
  "due_now": "Amount due now:",
864
865
  "interval_monthly": "monthly",
@@ -870,7 +871,8 @@
870
871
  "percent_off": "{value}% off",
871
872
  "processing": "Processing...",
872
873
  "promotion_code": "Promotion Code",
873
- "proration_description": "Your next charge will be adjusted to account for the plan change.",
874
+ "proration_credit_description": "The unused time becomes account credit, used on your next invoices.",
875
+ "proration_description": "The difference is charged today on a new invoice.",
874
876
  "proration_summary": "Proration Summary",
875
877
  "selected_plan": "Selected Plan",
876
878
  "subscribe_now": "Subscribe Now",
package/messages/it.json CHANGED
@@ -782,7 +782,7 @@
782
782
  },
783
783
  "proration": {
784
784
  "net_due_today": "Netto dovuto oggi",
785
- "next_invoice": "Prossima fattura il {date} di {amount}",
785
+ "next_invoice": "Addebito oggi: {amount}",
786
786
  "title": "Dettaglio del conguaglio"
787
787
  },
788
788
  "subscription": {
@@ -859,6 +859,7 @@
859
859
  "amount_off": "{amount} di sconto",
860
860
  "card_on_file": "{brand} che termina con {last4}",
861
861
  "charge_now": "Importo da addebitare ora:",
862
+ "credit_now": "Credito sul tuo account:",
862
863
  "confirm_change": "Conferma il cambio di piano",
863
864
  "due_now": "Importo dovuto ora:",
864
865
  "interval_monthly": "mensile",
@@ -870,7 +871,8 @@
870
871
  "percent_off": "{value}% di sconto",
871
872
  "processing": "Elaborazione...",
872
873
  "promotion_code": "Codice promozionale",
873
- "proration_description": "Il tuo prossimo addebito verrà adeguato per tenere conto del cambio di piano.",
874
+ "proration_credit_description": "Il tempo non usato diventa credito sul tuo account, usato nelle prossime fatture.",
875
+ "proration_description": "La differenza viene addebitata oggi con una nuova fattura.",
874
876
  "proration_summary": "Riepilogo del conguaglio",
875
877
  "selected_plan": "Piano selezionato",
876
878
  "subscribe_now": "Abbonati ora",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "6.9.0",
3
+ "version": "6.10.1",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -106,8 +106,10 @@ export type BlockNoteEditorProps = {
106
106
  stretch?: boolean;
107
107
  // Gates AI on top of `aiConfig` (credit availability, etc.). Defaults to
108
108
  // true so existing callers that only pass `aiConfig` are unaffected. When
109
- // false, no AI extension is created, the AI toolbar button and AI slash
110
- // items are hidden, and the editor falls back to the plain slash menu.
109
+ // false, the AI toolbar button and AI slash items are hidden, the editor
110
+ // falls back to the plain slash menu, an open AI menu or run is closed, and
111
+ // no AI request is sent. The AI extension itself stays: toggling this never
112
+ // rebuilds the editor, so unsaved text survives credits running out.
111
113
  aiEnabled?: boolean;
112
114
  // Filled with a {@link BlockNoteAiHandle} while the editor is mounted and AI
113
115
  // is on, so a control outside the editor can run a whole-document AI action.
@@ -508,11 +510,22 @@ export default function BlockNoteEditor({
508
510
  // instance exists; reads the current BlockNote selection on every send.
509
511
  const selectionRef = useRef<{ getSelectedBlocks: () => any[] }>({ getSelectedBlocks: () => [] });
510
512
 
511
- const companyId = company?.id;
513
+ // Callers pass `aiConfig` as an inline object, the company arrives after the
514
+ // first render and `aiEnabled` flips with credits, so none of them may key
515
+ // the AI extension: every new extension means a new editor, which restarts
516
+ // from `initialContent` and drops unsaved text. The transport and the outside
517
+ // handle read them through this ref at send time.
518
+ const aiRequestRef = useRef({ aiConfig, companyId: company?.id, aiEnabled });
519
+ useEffect(() => {
520
+ aiRequestRef.current = { aiConfig, companyId: company?.id, aiEnabled };
521
+ });
522
+
523
+ const hasAiConfig = !!aiConfig;
524
+ const aiEndpoint = aiConfig?.endpoint;
512
525
  const aiExtension = useMemo(() => {
513
- if (!aiConfig || !aiEnabled) return undefined;
526
+ if (!hasAiConfig || !aiEndpoint) return undefined;
514
527
  const base = getPublicApiUrl();
515
- const url = new URL(aiConfig.endpoint, base.endsWith("/") ? base : base + "/").toString();
528
+ const url = new URL(aiEndpoint, base.endsWith("/") ? base : base + "/").toString();
516
529
  return AIExtension({
517
530
  transport: new DefaultChatTransport({
518
531
  api: url,
@@ -521,10 +534,16 @@ export default function BlockNoteEditor({
521
534
  const headers: Record<string, string> = { "x-language": locale };
522
535
  const token = await getClientToken();
523
536
  if (token) headers["Authorization"] = `Bearer ${token}`;
537
+ const companyId = aiRequestRef.current.companyId;
524
538
  if (companyId) headers["x-companyid"] = companyId;
525
539
  return headers;
526
540
  },
527
541
  prepareSendMessagesRequest: ({ messages, body }: any) => {
542
+ // Last guard before the network: AI switched off while a request was
543
+ // being started never reaches the endpoint. Throwing here puts the
544
+ // AI menu in its error state instead of sending.
545
+ if (!aiRequestRef.current.aiEnabled) throw new Error("AI is disabled");
546
+ const latestAiConfig = aiRequestRef.current.aiConfig;
528
547
  let lastUserIdx = -1;
529
548
  for (let i = messages.length - 1; i >= 0; i--) {
530
549
  if (messages[i]?.role === "user") {
@@ -539,8 +558,8 @@ export default function BlockNoteEditor({
539
558
  ...m,
540
559
  metadata: {
541
560
  ...(m.metadata ?? {}),
542
- entityType: aiConfig.entityType,
543
- entityId: aiConfig.entityId,
561
+ entityType: latestAiConfig?.entityType,
562
+ entityId: latestAiConfig?.entityId,
544
563
  blocks: docRef.current.getDoc(),
545
564
  selectionBlocks: selectedBlocks,
546
565
  },
@@ -551,7 +570,7 @@ export default function BlockNoteEditor({
551
570
  },
552
571
  }),
553
572
  });
554
- }, [aiConfig, aiEnabled, companyId, locale]);
573
+ }, [hasAiConfig, aiEndpoint, locale]);
555
574
 
556
575
  const uploadImage = useCallback(
557
576
  async (file: File): Promise<string> => {
@@ -697,8 +716,42 @@ export default function BlockNoteEditor({
697
716
  }),
698
717
  [placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension, coreDictionary, aiDictionary],
699
718
  ),
719
+ // useCreateBlockNote memoises on THIS list, not on the options' identity
720
+ // (@blocknote/react: useMemo(() => BlockNoteEditor.create(options), deps),
721
+ // deps defaulting to []). Without it the editor keeps the extensions of its
722
+ // first render: AI config arriving later (user loaded after mount)
723
+ // rendered the AI UI over an editor with no AIExtension, and BlockNote's
724
+ // useExtension threw "Extension not found". The other options keep their
725
+ // first-render values, as before. `aiEnabled` is deliberately not here.
726
+ [aiExtension],
700
727
  );
701
728
 
729
+ // Every AI surface keys off the editor actually rendered, never off the
730
+ // props: those can disagree with the options that editor was created with.
731
+ // `aiEnabled` then hides that UI without touching the editor.
732
+ const hasAiExtension = !!editor.getExtension(AIExtension);
733
+ const aiActive = hasAiExtension && aiEnabled;
734
+
735
+ // AI switched off while its menu is open or a run is streaming: hiding the
736
+ // AIMenuController alone would leave the editor read-only (openAIMenuAtBlock
737
+ // sets isEditable = false) and the AI suggestions in the document. Stop the
738
+ // request, then reject the suggestions, or just close an idle prompt menu.
739
+ useEffect(() => {
740
+ if (aiEnabled || !hasAiExtension) return;
741
+ const ai = editor.getExtension(AIExtension);
742
+ if (!ai) return;
743
+ const dismiss = async () => {
744
+ const menu = ai.store.state.aiMenuState;
745
+ if (menu === "closed") return;
746
+ if (menu.status === "thinking" || menu.status === "ai-writing") await ai.abort("AI disabled");
747
+ const after = ai.store.state.aiMenuState;
748
+ if (after === "closed") return;
749
+ if (after.status === "user-input") ai.closeAIMenu();
750
+ else ai.rejectChanges();
751
+ };
752
+ dismiss().catch((error) => console.error("Closing the AI menu failed:", error));
753
+ }, [aiEnabled, hasAiExtension, editor]);
754
+
702
755
  // Publish the outside-the-editor AI trigger. `editor.getExtension(AIExtension)`
703
756
  // is BlockNote 0.54's documented lookup by extension factory (core's
704
757
  // `getExtension` resolves a function argument through `extensionFactories`),
@@ -709,12 +762,14 @@ export default function BlockNoteEditor({
709
762
  // the request runs but the accept / reject UI never appears.
710
763
  useEffect(() => {
711
764
  if (!aiHandleRef) return;
712
- if (!aiConfig || !aiEnabled) {
765
+ if (!aiActive) {
713
766
  aiHandleRef.current = null;
714
767
  return;
715
768
  }
716
769
  aiHandleRef.current = {
717
770
  invokeAi: (type: BlockNoteAiGenerateAction) => {
771
+ // A caller may hold the handle across the render that turns AI off.
772
+ if (!aiRequestRef.current.aiEnabled) return;
718
773
  const ai = editor.getExtension(AIExtension);
719
774
  if (!ai) return;
720
775
  editor.focus();
@@ -731,7 +786,7 @@ export default function BlockNoteEditor({
731
786
  return () => {
732
787
  aiHandleRef.current = null;
733
788
  };
734
- }, [aiHandleRef, aiConfig, aiEnabled, editor]);
789
+ }, [aiHandleRef, aiActive, editor]);
735
790
 
736
791
  // Tracks the hash of the document the editor itself just emitted via onChange.
737
792
  // The sync effect below uses it to skip replaceBlocks when the parent's
@@ -912,7 +967,7 @@ export default function BlockNoteEditor({
912
967
  onChange={handleChange}
913
968
  editable={onChange !== undefined}
914
969
  formattingToolbar={false}
915
- slashMenu={!aiConfig || !aiEnabled}
970
+ slashMenu={!aiActive}
916
971
  // Follow the app theme: hardcoding "light" leaves dark (light-theme)
917
972
  // text on the dark card, which is nearly unreadable in dark mode.
918
973
  theme={resolvedTheme === "dark" ? "dark" : "light"}
@@ -939,7 +994,7 @@ export default function BlockNoteEditor({
939
994
  size === "sm" && "small",
940
995
  )}
941
996
  >
942
- <BlockNoteEditorFormattingToolbar showAI={!!aiConfig && aiEnabled} />
997
+ <BlockNoteEditorFormattingToolbar showAI={aiActive} />
943
998
  {enableMentions && mentionSearchFn && (
944
999
  <BlockNoteEditorMentionSuggestionMenu
945
1000
  editor={editor}
@@ -952,7 +1007,7 @@ export default function BlockNoteEditor({
952
1007
  {enableMentions && mentionResolveFn && (
953
1008
  <BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
954
1009
  )}
955
- {aiConfig && aiEnabled && (
1010
+ {aiActive && (
956
1011
  <SuggestionMenuController
957
1012
  triggerCharacter="/"
958
1013
  getItems={async (query: string) =>
@@ -960,7 +1015,7 @@ export default function BlockNoteEditor({
960
1015
  }
961
1016
  />
962
1017
  )}
963
- {aiConfig && aiEnabled && <AIMenuController aiMenu={() => <NarrAIMenu actions={aiConfig.actions} />} />}
1018
+ {aiActive && <AIMenuController aiMenu={() => <NarrAIMenu actions={aiConfig?.actions} />} />}
964
1019
  </BlockNoteView>
965
1020
  </div>
966
1021
  );
@@ -0,0 +1,295 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import React from "react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * The AI menu, AI toolbar button and AI slash items call BlockNote's
7
+ * useExtension(AIExtension), which throws "Extension not found" when the
8
+ * editor in context was created without the extension. These stubs keep that
9
+ * contract: useCreateBlockNote memoises on its deps exactly like
10
+ * @blocknote/react 0.55 (useMemo(() => BlockNoteEditor.create(options), deps)),
11
+ * and the AI controllers throw the same error BlockNote throws.
12
+ */
13
+ const h = vi.hoisted(() => {
14
+ const AIExtension = vi.fn((options: unknown) => ({ aiFactory: true, options }));
15
+ return {
16
+ AIExtension,
17
+ editorsCreated: 0,
18
+ company: undefined as { id: string } | undefined,
19
+ // The last editor useCreateBlockNote built, and its AI extension instance.
20
+ editor: undefined as any,
21
+ ai: undefined as any,
22
+ // Config the editor handed to DefaultChatTransport (the AI request path).
23
+ transportConfig: undefined as any,
24
+ };
25
+ });
26
+
27
+ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
28
+ vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
29
+ vi.mock("../../../i18n/config", () => ({ useI18nLocale: () => "en" }));
30
+ vi.mock("../../../client/config", () => ({ getPublicApiUrl: () => "http://api.test/" }));
31
+ vi.mock("../../../client/token", () => ({ getClientToken: async () => "token" }));
32
+ vi.mock("../../../contexts", () => ({
33
+ useCurrentUserContextOptional: () => (h.company ? { company: h.company } : undefined),
34
+ }));
35
+
36
+ vi.mock("@blocknote/react", async () => {
37
+ const React = await import("react");
38
+ const EditorContext = React.createContext<any>(null);
39
+ const useBlockNoteEditor = () => React.useContext(EditorContext);
40
+ const requireAI = (editor: any) => {
41
+ if (!editor?.getExtension(h.AIExtension)) throw new Error("Extension not found");
42
+ };
43
+ return {
44
+ EditorContext,
45
+ createReactInlineContentSpec: (config: unknown) => ({ config }),
46
+ getDefaultReactSlashMenuItems: () => [],
47
+ SuggestionMenuController: () => null,
48
+ useBlockNoteEditor,
49
+ useExtension: () => {
50
+ const editor = useBlockNoteEditor();
51
+ requireAI(editor);
52
+ return editor.getExtension(h.AIExtension);
53
+ },
54
+ useExtensionState: () => "closed",
55
+ useCreateBlockNote: (options: any, deps: React.DependencyList = []) =>
56
+ React.useMemo(() => {
57
+ h.editorsCreated += 1;
58
+ const extensions: any[] = options?.extensions ?? [];
59
+ // Mirrors xl-ai 0.55: a store holding aiMenuState, and the calls that
60
+ // move it back to "closed".
61
+ const store = { state: { aiMenuState: "closed" as any } };
62
+ const ai = {
63
+ store,
64
+ invokeAI: vi.fn(),
65
+ openAIMenuAtBlock: vi.fn((blockId: string) => {
66
+ store.state.aiMenuState = { blockId, status: "user-input" };
67
+ }),
68
+ abort: vi.fn(async () => {
69
+ const menu = store.state.aiMenuState;
70
+ if (menu !== "closed") store.state.aiMenuState = { ...menu, status: "user-reviewing" };
71
+ }),
72
+ closeAIMenu: vi.fn(() => {
73
+ store.state.aiMenuState = "closed";
74
+ }),
75
+ rejectChanges: vi.fn(() => {
76
+ store.state.aiMenuState = "closed";
77
+ }),
78
+ };
79
+ const hasAI = extensions.some((e) => e?.aiFactory);
80
+ const editor = {
81
+ document: [] as any[],
82
+ focus: () => undefined,
83
+ getSelection: () => undefined,
84
+ getExtension: (factory: unknown) => (factory === h.AIExtension && hasAI ? ai : undefined),
85
+ };
86
+ h.editor = editor;
87
+ h.ai = hasAI ? ai : undefined;
88
+ return editor;
89
+ }, deps),
90
+ BasicTextStyleButton: () => null,
91
+ BlockTypeSelect: () => null,
92
+ CreateLinkButton: () => null,
93
+ FileCaptionButton: () => null,
94
+ FileReplaceButton: () => null,
95
+ TextAlignButton: () => null,
96
+ FormattingToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
97
+ FormattingToolbarController: ({ formattingToolbar }: { formattingToolbar: () => React.ReactNode }) => (
98
+ <>{formattingToolbar()}</>
99
+ ),
100
+ // Shared with the xl-ai stubs below.
101
+ __requireAI: requireAI,
102
+ };
103
+ });
104
+
105
+ vi.mock("@blocknote/shadcn", async () => {
106
+ const { EditorContext } = (await import("@blocknote/react")) as any;
107
+ return {
108
+ BlockNoteView: ({ editor, children }: { editor: unknown; children: React.ReactNode }) => (
109
+ <EditorContext.Provider value={editor}>{children}</EditorContext.Provider>
110
+ ),
111
+ };
112
+ });
113
+
114
+ vi.mock("@blocknote/xl-ai", async () => {
115
+ const { useBlockNoteEditor, __requireAI } = (await import("@blocknote/react")) as any;
116
+ return {
117
+ AIExtension: h.AIExtension,
118
+ AIMenuController: () => {
119
+ __requireAI(useBlockNoteEditor());
120
+ return <div data-testid="ai-menu" />;
121
+ },
122
+ AIToolbarButton: () => {
123
+ __requireAI(useBlockNoteEditor());
124
+ return <div data-testid="ai-toolbar-button" />;
125
+ },
126
+ getAISlashMenuItems: () => [],
127
+ getDefaultAIMenuItems: () => [],
128
+ PromptSuggestionMenu: () => null,
129
+ useAIDictionary: () => ({}),
130
+ };
131
+ });
132
+
133
+ vi.mock("ai", () => ({
134
+ DefaultChatTransport: function (this: any, config: unknown) {
135
+ h.transportConfig = config;
136
+ },
137
+ }));
138
+ vi.mock("@blocknote/core", () => ({
139
+ BlockNoteSchema: { create: (config: unknown) => ({ config }) },
140
+ defaultInlineContentSpecs: {},
141
+ filterSuggestionItems: (items: unknown[]) => items,
142
+ }));
143
+ vi.mock("@blocknote/core/locales", () => ({ en: { core: "en" } }));
144
+ vi.mock("@blocknote/xl-ai/locales", () => ({ en: { ai: "en" } }));
145
+ vi.mock("@blocknote/shadcn/style.css", () => ({}));
146
+ vi.mock("@blocknote/xl-ai/style.css", () => ({}));
147
+ vi.mock("../BlockNoteEditorMentionInlineContent", () => ({ createMentionInlineContentSpec: () => ({}) }));
148
+
149
+ import BlockNoteEditor, { type BlockNoteAiHandle } from "../BlockNoteEditor";
150
+
151
+ const onChange = vi.fn();
152
+
153
+ // Callers write aiConfig inline, so every parent render passes a new object.
154
+ const renderEditor = (
155
+ aiEnabled: boolean,
156
+ opts: { withConfig?: boolean; aiHandleRef?: React.MutableRefObject<BlockNoteAiHandle | null> } = {},
157
+ ) => (
158
+ <BlockNoteEditor
159
+ id="npc-1"
160
+ type="npcs"
161
+ onChange={onChange}
162
+ aiEnabled={aiEnabled}
163
+ aiHandleRef={opts.aiHandleRef}
164
+ aiConfig={
165
+ opts.withConfig === false
166
+ ? undefined
167
+ : { endpoint: "text-generation/blocknote", entityType: "npcs", actions: ["fill-template"] }
168
+ }
169
+ />
170
+ );
171
+
172
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
173
+
174
+ describe("BlockNoteEditor AI wiring", () => {
175
+ beforeEach(() => {
176
+ h.AIExtension.mockClear();
177
+ h.editorsCreated = 0;
178
+ h.company = { id: "company-1" };
179
+ });
180
+
181
+ it("does not rebuild the AI extension or the editor when a new but equal aiConfig object arrives", () => {
182
+ const { rerender } = render(renderEditor(true));
183
+ rerender(renderEditor(true));
184
+ rerender(renderEditor(true));
185
+
186
+ expect(h.AIExtension).toHaveBeenCalledTimes(1);
187
+ expect(h.editorsCreated).toBe(1);
188
+ expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
189
+ });
190
+
191
+ it("does not rebuild the AI extension when only the company loads", () => {
192
+ h.company = undefined;
193
+ const { rerender } = render(renderEditor(true));
194
+ h.company = { id: "company-1" };
195
+ rerender(renderEditor(true));
196
+
197
+ expect(h.AIExtension).toHaveBeenCalledTimes(1);
198
+ expect(h.editorsCreated).toBe(1);
199
+ });
200
+
201
+ it("renders no AI UI while the editor has no AI config", () => {
202
+ render(renderEditor(true, { withConfig: false }));
203
+
204
+ expect(h.AIExtension).not.toHaveBeenCalled();
205
+ expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
206
+ });
207
+
208
+ it("builds the AI extension but renders no AI UI while AI is disabled", () => {
209
+ render(renderEditor(false));
210
+
211
+ expect(h.AIExtension).toHaveBeenCalledTimes(1);
212
+ expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
213
+ expect(screen.queryByTestId("ai-toolbar-button")).not.toBeInTheDocument();
214
+ });
215
+
216
+ it("does not crash when the AI config arrives after mount, and then shows the AI menu", () => {
217
+ h.company = undefined;
218
+ const { rerender } = render(renderEditor(true, { withConfig: false }));
219
+ expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
220
+
221
+ h.company = { id: "company-1" };
222
+ expect(() => rerender(renderEditor(true))).not.toThrow();
223
+ expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
224
+ expect(h.AIExtension).toHaveBeenCalledTimes(1);
225
+ expect(h.editorsCreated).toBe(2);
226
+ });
227
+
228
+ it("never rebuilds the editor when aiEnabled toggles, keeps typed content, and hides/shows the AI UI", () => {
229
+ const { rerender } = render(renderEditor(true));
230
+ const editor = h.editor;
231
+ expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
232
+
233
+ // Text typed before credits run out.
234
+ editor.document.push({ type: "paragraph", content: "unsaved draft" });
235
+
236
+ rerender(renderEditor(false));
237
+ expect(screen.queryByTestId("ai-menu")).not.toBeInTheDocument();
238
+
239
+ rerender(renderEditor(true));
240
+ expect(screen.getByTestId("ai-menu")).toBeInTheDocument();
241
+
242
+ expect(h.editorsCreated).toBe(1);
243
+ expect(h.AIExtension).toHaveBeenCalledTimes(1);
244
+ expect(h.editor).toBe(editor);
245
+ expect(h.editor.document).toEqual([{ type: "paragraph", content: "unsaved draft" }]);
246
+ });
247
+
248
+ it("closes an open AI prompt menu without throwing when AI turns off", async () => {
249
+ const { rerender } = render(renderEditor(true));
250
+ const ai = h.ai;
251
+ ai.openAIMenuAtBlock("block-1");
252
+
253
+ expect(() => rerender(renderEditor(false))).not.toThrow();
254
+ await flush();
255
+
256
+ expect(ai.closeAIMenu).toHaveBeenCalledTimes(1);
257
+ expect(ai.rejectChanges).not.toHaveBeenCalled();
258
+ expect(ai.store.state.aiMenuState).toBe("closed");
259
+ });
260
+
261
+ it("aborts a running AI request and rejects its changes when AI turns off", async () => {
262
+ const { rerender } = render(renderEditor(true));
263
+ const ai = h.ai;
264
+ ai.store.state.aiMenuState = { blockId: "block-1", status: "ai-writing" };
265
+
266
+ expect(() => rerender(renderEditor(false))).not.toThrow();
267
+ await flush();
268
+
269
+ expect(ai.abort).toHaveBeenCalledTimes(1);
270
+ expect(ai.rejectChanges).toHaveBeenCalledTimes(1);
271
+ expect(ai.store.state.aiMenuState).toBe("closed");
272
+ });
273
+
274
+ it("sends no AI request while AI is disabled", () => {
275
+ const aiHandleRef: React.MutableRefObject<BlockNoteAiHandle | null> = { current: null };
276
+ const { rerender } = render(renderEditor(true, { aiHandleRef }));
277
+ const heldHandle = aiHandleRef.current;
278
+ expect(heldHandle).not.toBeNull();
279
+
280
+ rerender(renderEditor(false, { aiHandleRef }));
281
+ expect(aiHandleRef.current).toBeNull();
282
+
283
+ // A handle kept across the toggle does nothing.
284
+ heldHandle!.invokeAi("fill-template");
285
+ expect(h.ai.invokeAI).not.toHaveBeenCalled();
286
+
287
+ // The transport refuses to build a request.
288
+ const prepare = h.transportConfig.prepareSendMessagesRequest;
289
+ expect(() => prepare({ messages: [{ role: "user", parts: [] }], body: {} })).toThrow("AI is disabled");
290
+
291
+ rerender(renderEditor(true, { aiHandleRef }));
292
+ const prepared = prepare({ messages: [{ role: "user", parts: [] }], body: {} });
293
+ expect(prepared.body.messages[0].metadata.entityType).toBe("npcs");
294
+ });
295
+ });
@@ -37,6 +37,12 @@ interface Props {
37
37
  * disable, never replacing it.
38
38
  */
39
39
  disabled?: boolean;
40
+ /**
41
+ * Rendered in place of the composer: the threads stay readable and no
42
+ * message can be sent — the suggested questions render disabled too (e.g.
43
+ * the consuming app's plan gate). Omitted, the composer renders as before.
44
+ */
45
+ composerNotice?: React.ReactNode;
40
46
  }
41
47
 
42
48
  /**
@@ -54,6 +60,7 @@ export function AssistantPageContainer({
54
60
  detailsTitle,
55
61
  detailsIcon,
56
62
  disabled,
63
+ composerNotice,
57
64
  }: Props) {
58
65
  const t = useTranslations();
59
66
  const ctx = useAssistantContext();
@@ -98,7 +105,7 @@ export function AssistantPageContainer({
98
105
  messages={ctx.messages}
99
106
  sending={ctx.sending}
100
107
  status={ctx.status}
101
- onSelectFollowUp={ctx.sendMessage}
108
+ onSelectFollowUp={composerNotice ? undefined : ctx.sendMessage}
102
109
  failedMessageIds={ctx.failedMessageIds}
103
110
  onRetry={ctx.retrySend}
104
111
  renderApprovalAction={renderApprovalAction}
@@ -116,14 +123,18 @@ export function AssistantPageContainer({
116
123
  </div>
117
124
  </div>
118
125
  )}
119
- <AssistantBlockNoteComposer
120
- onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
121
- disabled={ctx.sending || disabled}
122
- mentionSearchFn={mentionSearchFn}
123
- mentionSearchParams={mentionSearchParams}
124
- mentionResolveFn={mentionResolveFn}
125
- suggestionMenuComponent={suggestionMenuComponent}
126
- />
126
+ {composerNotice ? (
127
+ <div className="border-t p-4">{composerNotice}</div>
128
+ ) : (
129
+ <AssistantBlockNoteComposer
130
+ onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
131
+ disabled={ctx.sending || disabled}
132
+ mentionSearchFn={mentionSearchFn}
133
+ mentionSearchParams={mentionSearchParams}
134
+ mentionResolveFn={mentionResolveFn}
135
+ suggestionMenuComponent={suggestionMenuComponent}
136
+ />
137
+ )}
127
138
  </div>
128
139
  </RoundPageContainer>
129
140
  );