@carlonicora/nextjs-jsonapi 3.22.0 → 3.24.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 (102) hide show
  1. package/dist/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
  2. package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
  3. package/dist/{BlockNoteEditor-VEEMWB2J.js → BlockNoteEditor-PTD2V4CH.js} +19 -19
  4. package/dist/{BlockNoteEditor-VEEMWB2J.js.map → BlockNoteEditor-PTD2V4CH.js.map} +1 -1
  5. package/dist/{BlockNoteEditor-5QBGVWE7.mjs → BlockNoteEditor-Q2WEDMKJ.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs → BlockNoteViewer-WO6GTDSI.mjs} +2 -2
  7. package/dist/{BlockNoteViewer-XCGBX4ST.js → BlockNoteViewer-WOXEJ6RM.js} +3 -3
  8. package/dist/{BlockNoteViewer-XCGBX4ST.js.map → BlockNoteViewer-WOXEJ6RM.js.map} +1 -1
  9. package/dist/{HandbookSectionInterface-DqU_d0sy.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +36 -0
  10. package/dist/{HandbookSectionInterface-DFj2zErb.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +36 -0
  11. package/dist/billing/index.js +401 -401
  12. package/dist/billing/index.mjs +3 -3
  13. package/dist/{chunk-CK47KSFV.mjs → chunk-2O3SFHQ7.mjs} +91 -39
  14. package/dist/chunk-2O3SFHQ7.mjs.map +1 -0
  15. package/dist/{chunk-SYQMVTIC.js → chunk-3D2MCVRZ.js} +7 -7
  16. package/dist/{chunk-SYQMVTIC.js.map → chunk-3D2MCVRZ.js.map} +1 -1
  17. package/dist/{chunk-XYSWOW5G.js → chunk-3YJF7QY4.js} +1358 -1116
  18. package/dist/chunk-3YJF7QY4.js.map +1 -0
  19. package/dist/{chunk-5G7I3OUB.mjs → chunk-GJHKOG5L.mjs} +1419 -1177
  20. package/dist/chunk-GJHKOG5L.mjs.map +1 -0
  21. package/dist/{chunk-U55JQ4NY.mjs → chunk-HAI7G6MD.mjs} +2 -2
  22. package/dist/{chunk-MWZCCOTS.js → chunk-IV4DTYXN.js} +91 -39
  23. package/dist/chunk-IV4DTYXN.js.map +1 -0
  24. package/dist/client/index.d.mts +13 -2
  25. package/dist/client/index.d.ts +13 -2
  26. package/dist/client/index.js +4 -4
  27. package/dist/client/index.mjs +3 -3
  28. package/dist/components/index.d.mts +90 -31
  29. package/dist/components/index.d.ts +90 -31
  30. package/dist/components/index.js +8 -4
  31. package/dist/components/index.js.map +1 -1
  32. package/dist/components/index.mjs +7 -3
  33. package/dist/contexts/index.d.mts +11 -2
  34. package/dist/contexts/index.d.ts +11 -2
  35. package/dist/contexts/index.js +4 -4
  36. package/dist/contexts/index.mjs +3 -3
  37. package/dist/core/index.d.mts +29 -7
  38. package/dist/core/index.d.ts +29 -7
  39. package/dist/core/index.js +2 -2
  40. package/dist/core/index.mjs +1 -1
  41. package/dist/features/help/index.js +37 -37
  42. package/dist/features/help/index.mjs +3 -3
  43. package/dist/features/tokenusage/index.js +101 -101
  44. package/dist/features/tokenusage/index.mjs +3 -3
  45. package/dist/index.d.mts +3 -4
  46. package/dist/index.d.ts +3 -4
  47. package/dist/index.js +3 -3
  48. package/dist/index.mjs +2 -2
  49. package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
  50. package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
  51. package/dist/server/index.js +3 -3
  52. package/dist/server/index.mjs +1 -1
  53. package/package.json +1 -1
  54. package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
  55. package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
  56. package/src/features/ai-connection/components/forms/AiConnectionEditor.tsx +1 -0
  57. package/src/features/ai-connection/components/lists/AiConnectionTypeCard.tsx +9 -0
  58. package/src/features/ai-connection/data/__tests__/ai-connection.test.ts +51 -0
  59. package/src/features/ai-connection/data/ai-connection.interface.ts +18 -0
  60. package/src/features/ai-connection/data/ai-connection.ts +63 -0
  61. package/src/features/ai-connection/i18n-keys.ts +9 -0
  62. package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
  63. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
  64. package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
  65. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
  66. package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
  67. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
  68. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
  69. package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
  70. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
  71. package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
  72. package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
  73. package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
  74. package/src/features/assistant-message/components/index.ts +1 -0
  75. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
  76. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
  77. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
  78. package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
  79. package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
  80. package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
  81. package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
  82. package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
  83. package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
  84. package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
  85. package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
  86. package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
  87. package/src/features/oauth/components/consent/index.ts +1 -0
  88. package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
  89. package/src/features/oauth/data/oauth.service.ts +3 -13
  90. package/src/features/oauth/data/oauth.ts +30 -1
  91. package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
  92. package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
  93. package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
  94. package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
  95. package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
  96. package/dist/chunk-5G7I3OUB.mjs.map +0 -1
  97. package/dist/chunk-CK47KSFV.mjs.map +0 -1
  98. package/dist/chunk-MWZCCOTS.js.map +0 -1
  99. package/dist/chunk-XYSWOW5G.js.map +0 -1
  100. /package/dist/{BlockNoteEditor-5QBGVWE7.mjs.map → BlockNoteEditor-Q2WEDMKJ.mjs.map} +0 -0
  101. /package/dist/{BlockNoteViewer-ZY4ZF3HO.mjs.map → BlockNoteViewer-WO6GTDSI.mjs.map} +0 -0
  102. /package/dist/{chunk-U55JQ4NY.mjs.map → chunk-HAI7G6MD.mjs.map} +0 -0
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { PlusCircleIcon } from "lucide-react";
3
4
  import { useTranslations } from "next-intl";
4
5
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
6
  import { SharedProvider } from "../../../contexts";
7
+ import { Button } from "../../../shadcnui";
6
8
  import { useSocketContext } from "../../../contexts/SocketContext";
7
9
  import { BreadcrumbItemData, JsonApiHydratedDataInterface, Modules, rehydrate, rehydrateList } from "../../../core";
8
10
  import { usePageUrlGenerator } from "../../../hooks";
@@ -40,6 +42,10 @@ interface AssistantContextValue {
40
42
  setOperatorMode(value: boolean): void;
41
43
  /** Appends a message returned outside the send flow (e.g. an approve/deny resume). */
42
44
  appendResolvedMessage(message: AssistantMessageInterface): void;
45
+ /** The scope a NEW thread will bind to. Starts from the provider's `scope` prop. */
46
+ scope?: { type: string; id: string };
47
+ /** Changes the scope a new thread binds to. Ignored once a thread is active. */
48
+ setScope(scope?: { type: string; id: string }): void;
43
49
  }
44
50
 
45
51
  const AssistantContext = createContext<AssistantContextValue | undefined>(undefined);
@@ -141,6 +147,9 @@ export function AssistantProvider({
141
147
  const [operatorMode, setOperatorMode] = useState<boolean>(
142
148
  () => dehydratedAssistant?.jsonApi?.attributes?.engine === "operator",
143
149
  );
150
+ // The scope the next created thread binds to. Starts from the `scope` prop
151
+ // and can be changed by the host (e.g. a scope picker) until a thread exists.
152
+ const [pendingScope, setPendingScope] = useState<{ type: string; id: string } | undefined>(scope);
144
153
  const { socket } = useSocketContext();
145
154
 
146
155
  const sendMessage = useCallback(
@@ -172,6 +181,35 @@ export function AssistantProvider({
172
181
  };
173
182
  socket?.on("assistant:status", handler);
174
183
 
184
+ // A new thread is named and persisted before its answer runs; the server
185
+ // announces it on `assistant:created` so the thread list and the URL
186
+ // update immediately instead of when the long create request returns.
187
+ let createdHandled = false;
188
+ const createdHandler = (payload: {
189
+ assistant?: { data?: JsonApiHydratedDataInterface["jsonApi"]; included?: any[] };
190
+ }) => {
191
+ if (createdHandled) return;
192
+ const data = payload?.assistant?.data;
193
+ if (!data || typeof data !== "object" || typeof data.id !== "string" || !data.id) return;
194
+ let thread: AssistantInterface;
195
+ try {
196
+ thread = rehydrate<AssistantInterface>(Modules.Assistant, {
197
+ jsonApi: data,
198
+ included: payload.assistant?.included ?? [],
199
+ });
200
+ } catch {
201
+ return;
202
+ }
203
+ if (!thread?.id) return;
204
+ createdHandled = true;
205
+ setAssistant(thread);
206
+ setThreads((prev) => [thread, ...prev.filter((row) => row.id !== thread.id)]);
207
+ if (manageUrl && typeof window !== "undefined") {
208
+ window.history.replaceState(null, "", resolveThreadUrl(thread.id));
209
+ }
210
+ };
211
+ if (!assistant) socket?.on("assistant:created", createdHandler);
212
+
175
213
  try {
176
214
  if (!assistant) {
177
215
  const input = {
@@ -181,7 +219,7 @@ export function AssistantProvider({
181
219
  handbookMode: opts?.handbookMode ?? retrievalMode?.handbookMode,
182
220
  limitToHandbookPageId: opts?.limitToHandbookPageId ?? retrievalMode?.limitToHandbookPageId,
183
221
  contentBlocks: opts?.contentBlocks,
184
- boundContent: scope,
222
+ boundContent: pendingScope,
185
223
  };
186
224
  const created = operatorMode
187
225
  ? await AssistantService.createOperator(input)
@@ -189,7 +227,11 @@ export function AssistantProvider({
189
227
  const msgs = await AssistantMessageService.findByAssistant({ assistantId: created.id });
190
228
  setAssistant(created);
191
229
  setMessages(msgs);
192
- setThreads((prev) => [created, ...prev]);
230
+ setThreads((prev) =>
231
+ prev.some((row) => row.id === created.id)
232
+ ? prev.map((row) => (row.id === created.id ? created : row))
233
+ : [created, ...prev],
234
+ );
193
235
  if (manageUrl && typeof window !== "undefined") {
194
236
  window.history.replaceState(null, "", resolveThreadUrl(created.id));
195
237
  }
@@ -219,11 +261,12 @@ export function AssistantProvider({
219
261
  });
220
262
  } finally {
221
263
  socket?.off("assistant:status", handler);
264
+ if (!assistant) socket?.off("assistant:created", createdHandler);
222
265
  setSending(false);
223
266
  setStatus(undefined);
224
267
  }
225
268
  },
226
- [assistant, messages, socket, operatorMode, scope, retrievalMode, manageUrl, resolveThreadUrl],
269
+ [assistant, messages, socket, operatorMode, pendingScope, retrievalMode, manageUrl, resolveThreadUrl],
227
270
  );
228
271
 
229
272
  const appendResolvedMessage = useCallback((message: AssistantMessageInterface) => {
@@ -278,10 +321,20 @@ export function AssistantProvider({
278
321
  setMessages([]);
279
322
  setFailedMessageIds(new Set());
280
323
  setOperatorMode(false);
324
+ setPendingScope(scope);
281
325
  if (manageUrl && typeof window !== "undefined") {
282
326
  window.history.replaceState(null, "", resolveThreadUrl());
283
327
  }
284
- }, [manageUrl, resolveThreadUrl]);
328
+ }, [manageUrl, resolveThreadUrl, scope]);
329
+
330
+ const setScope = useCallback(
331
+ (next?: { type: string; id: string }) => {
332
+ // An existing thread is already bound; its scope cannot change.
333
+ if (assistant) return;
334
+ setPendingScope(next);
335
+ },
336
+ [assistant],
337
+ );
285
338
 
286
339
  const deleteThread = useCallback(async (id: string) => {
287
340
  await AssistantService.delete({ id });
@@ -329,6 +382,8 @@ export function AssistantProvider({
329
382
  operatorMode,
330
383
  setOperatorMode,
331
384
  appendResolvedMessage,
385
+ scope: pendingScope,
386
+ setScope,
332
387
  }),
333
388
  [
334
389
  assistant,
@@ -346,6 +401,8 @@ export function AssistantProvider({
346
401
  deleteThread,
347
402
  operatorMode,
348
403
  appendResolvedMessage,
404
+ pendingScope,
405
+ setScope,
349
406
  ],
350
407
  );
351
408
 
@@ -360,9 +417,36 @@ export function AssistantProvider({
360
417
 
361
418
  // `entities.tasks` was a copy-paste from another feature and rendered
362
419
  // "Tasks" as the page heading of every assistant page.
363
- const title = {
364
- type: titleOverride ?? t("entities.assistants", { count: 2 }),
365
- };
420
+ // Shaped like the conversations page heading: the open thread's title as the
421
+ // element, and the new-thread action in the shared header so it stays
422
+ // reachable when the thread-list panel is collapsed.
423
+ const title = useMemo(() => {
424
+ const response: { type: string; element?: string; functions?: React.ReactNode } = {
425
+ type: titleOverride ?? t("entities.assistants", { count: assistant ? 1 : 2 }),
426
+ };
427
+
428
+ if (assistant?.title) {
429
+ response.element = assistant.title.length > 25 ? `${assistant.title.substring(0, 25)}...` : assistant.title;
430
+ }
431
+
432
+ response.functions = [
433
+ <Button
434
+ key="new-assistant"
435
+ data-testid="assistant-new-button"
436
+ size="sm"
437
+ variant={`outline`}
438
+ onClick={(e) => {
439
+ e.preventDefault();
440
+ startNew();
441
+ }}
442
+ >
443
+ <PlusCircleIcon className="me-2 h-3.5 w-3.5" />
444
+ {t("features.assistant.new")}
445
+ </Button>,
446
+ ];
447
+
448
+ return response;
449
+ }, [assistant, startNew, t, titleOverride]);
366
450
 
367
451
  return (
368
452
  <AssistantContext.Provider value={value}>
@@ -4,6 +4,8 @@ import { AssistantProvider, useAssistantContext } from "../AssistantContext";
4
4
  import { AssistantService } from "../../data/AssistantService";
5
5
  import { AssistantMessageService } from "../../../assistant-message/data/AssistantMessageService";
6
6
  import { useSocketContext } from "../../../../contexts/SocketContext";
7
+ import { useSharedContext } from "../../../../contexts";
8
+ import { render, screen, fireEvent } from "@testing-library/react";
7
9
  import type { JsonApiHydratedDataInterface } from "../../../../core";
8
10
  import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
9
11
  import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
@@ -636,4 +638,198 @@ describe("AssistantContext", () => {
636
638
 
637
639
  replaceState.mockRestore();
638
640
  });
641
+
642
+ it("setScope binds the first message's thread", async () => {
643
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
644
+ const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-scoped" }));
645
+ AssistantService.create = create;
646
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
647
+
648
+ const { result } = renderHook(() => useAssistantContext(), {
649
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
650
+ });
651
+ expect(result.current.scope).toBeUndefined();
652
+
653
+ act(() => {
654
+ result.current.setScope({ type: "proceedings", id: "p1" });
655
+ });
656
+ expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
657
+
658
+ await act(async () => {
659
+ await result.current.sendMessage("hello");
660
+ });
661
+
662
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "proceedings", id: "p1" } }));
663
+
664
+ // Once a thread exists its scope is fixed.
665
+ act(() => {
666
+ result.current.setScope({ type: "proceedings", id: "p2" });
667
+ });
668
+ expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
669
+ replaceState.mockRestore();
670
+ });
671
+
672
+ it("the provider scope prop still filters the thread list", async () => {
673
+ const findMany = vi.fn().mockResolvedValue([]);
674
+ AssistantService.findMany = findMany;
675
+
676
+ const scope = { type: "campaigns", id: "camp-1" };
677
+ const { result } = renderHook(() => useAssistantContext(), {
678
+ wrapper: ({ children }) => <AssistantProvider scope={scope}>{children}</AssistantProvider>,
679
+ });
680
+
681
+ await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" }));
682
+ expect(result.current.scope).toEqual(scope);
683
+
684
+ act(() => {
685
+ result.current.setScope({ type: "proceedings", id: "p1" });
686
+ });
687
+
688
+ expect(findMany).not.toHaveBeenCalledWith({ boundType: "proceedings", boundId: "p1" });
689
+ expect(findMany).toHaveBeenLastCalledWith({ boundType: "campaigns", boundId: "camp-1" });
690
+ });
691
+
692
+ it("page heading: thread title as element and a new-thread button that resets the thread", async () => {
693
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
694
+ const existing = buildAssistantDehydrated({ id: "a-h", title: "A thread title longer than 25 chars" });
695
+ let ctx: ReturnType<typeof useAssistantContext> | undefined;
696
+ function Probe() {
697
+ ctx = useAssistantContext();
698
+ const { title } = useSharedContext();
699
+ return (
700
+ <div>
701
+ <span data-testid="element">{title.element}</span>
702
+ {title.functions}
703
+ </div>
704
+ );
705
+ }
706
+ render(
707
+ <AssistantProvider dehydratedAssistant={existing}>
708
+ <Probe />
709
+ </AssistantProvider>,
710
+ );
711
+ expect(screen.getByTestId("element")).toHaveTextContent("A thread title longer tha...");
712
+ fireEvent.click(screen.getByTestId("assistant-new-button"));
713
+ await waitFor(() => expect(ctx?.assistant).toBeUndefined());
714
+ expect(screen.getByTestId("element")).toBeEmptyDOMElement();
715
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants");
716
+ });
717
+
718
+ describe("assistant:created", () => {
719
+ function fakeSocket() {
720
+ const handlers: Record<string, (payload: any) => void> = {};
721
+ const socket = {
722
+ on: vi.fn((evt: string, h: any) => {
723
+ handlers[evt] = h;
724
+ }),
725
+ off: vi.fn((evt: string) => {
726
+ delete handlers[evt];
727
+ }),
728
+ };
729
+ vi.mocked(useSocketContext).mockReturnValue({ socket, isConnected: true } as any);
730
+ return { socket, handlers };
731
+ }
732
+
733
+ function createdEvent(id: string, title: string) {
734
+ return {
735
+ assistant: {
736
+ data: { type: "assistants", id, attributes: { title, messageCount: 0 } },
737
+ included: [],
738
+ },
739
+ };
740
+ }
741
+
742
+ it("names the thread, lists it and swaps the URL before the create request returns", async () => {
743
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
744
+ const { socket, handlers } = fakeSocket();
745
+ let resolveCreate!: (value: unknown) => void;
746
+ AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
747
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([buildMessageStub({ role: "user" })]);
748
+
749
+ const { result } = renderHook(() => useAssistantContext(), {
750
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
751
+ });
752
+ await waitFor(() => expect(result.current.threadsLoading).toBe(false));
753
+
754
+ let sendPromise: Promise<void>;
755
+ await act(async () => {
756
+ sendPromise = result.current.sendMessage("first question");
757
+ await Promise.resolve();
758
+ });
759
+ expect(socket.on).toHaveBeenCalledWith("assistant:created", expect.any(Function));
760
+
761
+ await act(async () => {
762
+ handlers["assistant:created"]({ assistant: { data: null } });
763
+ handlers["assistant:created"](createdEvent("a-new", "Named thread"));
764
+ handlers["assistant:created"](createdEvent("a-other", "Second event"));
765
+ });
766
+
767
+ expect(result.current.assistant?.id).toBe("a-new");
768
+ expect(result.current.assistant?.title).toBe("Named thread");
769
+ expect(result.current.threads.map((t) => t.id)).toEqual(["a-new"]);
770
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-new");
771
+ expect(result.current.sending).toBe(true);
772
+ expect(result.current.messages.map((m) => m.content)).toEqual(["first question"]);
773
+
774
+ await act(async () => {
775
+ resolveCreate(buildAssistantStub({ id: "a-new", title: "Named thread" }));
776
+ await sendPromise!;
777
+ });
778
+
779
+ expect(result.current.threads.filter((t) => t.id === "a-new")).toHaveLength(1);
780
+ expect(result.current.threads).toHaveLength(1);
781
+ expect(result.current.assistant?.id).toBe("a-new");
782
+ expect(socket.off).toHaveBeenCalledWith("assistant:created", expect.any(Function));
783
+ replaceState.mockRestore();
784
+ });
785
+
786
+ it("does not touch the URL from the event when manageUrl is false", async () => {
787
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
788
+ const { handlers } = fakeSocket();
789
+ let resolveCreate!: (value: unknown) => void;
790
+ AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
791
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
792
+
793
+ const { result } = renderHook(() => useAssistantContext(), {
794
+ wrapper: ({ children }) => <AssistantProvider manageUrl={false}>{children}</AssistantProvider>,
795
+ });
796
+ await waitFor(() => expect(result.current.threadsLoading).toBe(false));
797
+
798
+ let sendPromise: Promise<void>;
799
+ await act(async () => {
800
+ sendPromise = result.current.sendMessage("hello");
801
+ await Promise.resolve();
802
+ });
803
+ await act(async () => {
804
+ handlers["assistant:created"](createdEvent("a-sheet", "Sheet thread"));
805
+ });
806
+
807
+ expect(result.current.assistant?.id).toBe("a-sheet");
808
+ expect(result.current.threads.map((t) => t.id)).toEqual(["a-sheet"]);
809
+ expect(replaceState).not.toHaveBeenCalled();
810
+
811
+ await act(async () => {
812
+ resolveCreate(buildAssistantStub({ id: "a-sheet", title: "Sheet thread" }));
813
+ await sendPromise!;
814
+ });
815
+ expect(replaceState).not.toHaveBeenCalled();
816
+ replaceState.mockRestore();
817
+ });
818
+
819
+ it("does not subscribe to assistant:created when appending to an existing thread", async () => {
820
+ const { socket } = fakeSocket();
821
+ AssistantService.appendMessage = vi.fn().mockResolvedValue([]);
822
+ const { result } = renderHook(() => useAssistantContext(), {
823
+ wrapper: ({ children }) => (
824
+ <AssistantProvider dehydratedAssistant={buildAssistantDehydrated({ id: "a-ex" })}>
825
+ {children}
826
+ </AssistantProvider>
827
+ ),
828
+ });
829
+ await act(async () => {
830
+ await result.current.sendMessage("follow-up");
831
+ });
832
+ expect(socket.on).not.toHaveBeenCalledWith("assistant:created", expect.any(Function));
833
+ });
834
+ });
639
835
  });
@@ -1,21 +1,43 @@
1
1
  "use client";
2
2
 
3
+ import type { ReactNode } from "react";
4
+ import type { MentionRenderer } from "../../assistant-message/components/MessageItem";
3
5
  import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
4
6
  import { AssistantContainer } from "../../assistant/components/containers/AssistantContainer";
7
+ import type { AssistantInterface } from "../../assistant/data/AssistantInterface";
5
8
  import { useAssistantContext } from "../../assistant/contexts/AssistantContext";
6
9
  import { ApprovalActionCard } from "./ApprovalActionCard";
7
10
 
11
+ interface Props {
12
+ renderMention?: MentionRenderer;
13
+ embedded?: boolean;
14
+ renderScopePicker?: () => ReactNode;
15
+ renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
16
+ showOperatorToggle?: boolean;
17
+ }
18
+
8
19
  /**
9
20
  * AssistantContainer with the operator approval card wired in. The container
10
21
  * renders `approval-request` messages through the `renderApprovalAction` slot,
11
22
  * which stays a slot so the plain chat container has no dependency on the
12
23
  * AssistantAction module. Must be rendered inside an `AssistantProvider`.
13
24
  */
14
- export function AssistantContainerWithApprovals() {
25
+ export function AssistantContainerWithApprovals({
26
+ renderMention,
27
+ embedded,
28
+ renderScopePicker,
29
+ renderThreadBadge,
30
+ showOperatorToggle,
31
+ }: Props = {}) {
15
32
  const ctx = useAssistantContext();
16
33
 
17
34
  return (
18
35
  <AssistantContainer
36
+ renderMention={renderMention}
37
+ embedded={embedded}
38
+ renderScopePicker={renderScopePicker}
39
+ renderThreadBadge={renderThreadBadge}
40
+ showOperatorToggle={showOperatorToggle}
19
41
  renderApprovalAction={(message: AssistantMessageInterface) =>
20
42
  message.actionId ? (
21
43
  <ApprovalActionCard
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
4
+ import { usePageUrlGenerator } from "../../../hooks";
5
+ import { Link } from "../../../shadcnui";
6
+ import type { MentionRenderer } from "./MessageItem";
7
+
8
+ function EntityMentionLinkView({ type, id, alias }: { type: string; id: string; alias: string }) {
9
+ const generate = usePageUrlGenerator();
10
+
11
+ let module;
12
+ try {
13
+ module = ModuleRegistry.findByName(type);
14
+ } catch {
15
+ return <>{alias}</>;
16
+ }
17
+ if (!module?.pageUrl) return <>{alias}</>;
18
+
19
+ return <Link href={generate({ page: module, id })}>{alias}</Link>;
20
+ }
21
+
22
+ /**
23
+ * Default mention renderer for assistant answers: a `mention://<type>/<id>`
24
+ * link becomes a link to the entity's page when `<type>` is a registered
25
+ * module with a `pageUrl`, and plain text otherwise. Rendered as a component
26
+ * so the URL hook runs inside React's component tree.
27
+ */
28
+ export const EntityMentionLink: MentionRenderer = (p) => <EntityMentionLinkView {...p} />;
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { configureI18n } from "../../../../i18n";
5
+ import { EntityMentionLink } from "../EntityMentionLink";
6
+
7
+ beforeAll(() => {
8
+ // The package Link resolves its inner component at runtime and throws if i18n
9
+ // was never configured (src/i18n/config.ts).
10
+ configureI18n({
11
+ useRouter: () => ({
12
+ push: vi.fn(),
13
+ replace: vi.fn(),
14
+ back: vi.fn(),
15
+ forward: vi.fn(),
16
+ refresh: vi.fn(),
17
+ prefetch: vi.fn(),
18
+ }),
19
+ useTranslations: () => (key: string) => key,
20
+ usePathname: () => "/",
21
+ Link: ({
22
+ href,
23
+ children,
24
+ prefetch: _prefetch,
25
+ ...rest
26
+ }: {
27
+ href: string;
28
+ children: React.ReactNode;
29
+ [key: string]: any;
30
+ }) => (
31
+ <a href={href} {...rest}>
32
+ {children}
33
+ </a>
34
+ ),
35
+ });
36
+ ModuleRegistry.register("Account" as any, { name: "accounts", pageUrl: "/accounts" } as any);
37
+ });
38
+
39
+ describe("EntityMentionLink", () => {
40
+ it("renders a link to the entity page for a registered module", () => {
41
+ render(<>{EntityMentionLink({ type: "accounts", id: "a1", alias: "Rossi" })}</>);
42
+ const link = screen.getByRole("link", { name: "Rossi" });
43
+ expect(link).toHaveAttribute("href", "/accounts/a1");
44
+ expect(link).toHaveTextContent("Rossi");
45
+ });
46
+
47
+ it("renders through the package Link (locale-aware, link typography role)", () => {
48
+ render(<>{EntityMentionLink({ type: "accounts", id: "a1", alias: "Rossi" })}</>);
49
+ const link = screen.getByRole("link", { name: "Rossi" });
50
+ expect(link.className).toContain("text-primary font-medium");
51
+ });
52
+
53
+ it("renders plain text with no link for an unknown type", () => {
54
+ render(<>{EntityMentionLink({ type: "unknown-things", id: "x1", alias: "Mystery" })}</>);
55
+ expect(screen.getByText("Mystery")).toBeInTheDocument();
56
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
57
+ });
58
+ });
@@ -1,3 +1,4 @@
1
1
  export * from "./MessageItem";
2
2
  export * from "./MessageList";
3
3
  export { MessageSourcesPanel } from "./parts/MessageSourcesPanel";
4
+ export * from "./EntityMentionLink";
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import Link from "next/link";
5
4
  import type { ApiDataInterface } from "../../../../../core";
6
5
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
6
  import { usePageUrlGenerator } from "../../../../../hooks";
8
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
8
  import type { ChunkInterface, ChunkRelationshipMeta } from "../../../../chunk/data/ChunkInterface";
10
9
  import { useEntityLabel } from "./useEntityLabel";
10
+ import { Link } from "../../../../../shadcnui/custom/link";
11
11
 
12
12
  interface Props {
13
13
  citations: (ChunkInterface & ChunkRelationshipMeta)[];
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import Link from "next/link";
5
4
  import type { ApiDataInterface } from "../../../../../core";
6
5
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
6
  import { usePageUrlGenerator } from "../../../../../hooks";
8
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
8
  import { useEntityLabel } from "./useEntityLabel";
9
+ import { Link } from "../../../../../shadcnui/custom/link";
10
10
 
11
11
  interface Props {
12
12
  references: ApiDataInterface[];
@@ -42,7 +42,7 @@ export function ReferencesTab({ references }: Props) {
42
42
  return (
43
43
  <TableRow key={`${ref.type}/${ref.id}`}>
44
44
  <TableCell>
45
- <Link href={href} target="_blank" rel="noopener noreferrer" className="font-medium">
45
+ <Link href={href} target="_blank" rel="noopener noreferrer">
46
46
  {ref.identifier}
47
47
  </Link>
48
48
  </TableCell>
@@ -37,7 +37,8 @@ describe("Logout", () => {
37
37
  // We need to delete the original location first
38
38
  // @ts-expect-error - deleting window.location for mocking purposes
39
39
  delete window.location;
40
- window.location = {
40
+ // The DOM lib types `window.location` as accessor-only; jsdom lets the test replace it.
41
+ (window as unknown as { location: Location }).location = {
41
42
  ...originalLocation,
42
43
  get href() {
43
44
  return windowLocationHref;
@@ -50,7 +51,7 @@ describe("Logout", () => {
50
51
 
51
52
  afterEach(() => {
52
53
  // Restore original location
53
- window.location = originalLocation;
54
+ (window as unknown as { location: Location }).location = originalLocation;
54
55
  });
55
56
 
56
57
  describe("Scenario: Clears storage when storageKeys provided", () => {
@@ -17,7 +17,12 @@ import {
17
17
  } from "../../../shadcnui";
18
18
  import { OAuthRedirectUriInput } from "./OAuthRedirectUriInput";
19
19
  import { OAuthScopeSelector } from "./OAuthScopeSelector";
20
- import { OAuthClientCreateRequest, OAuthClientInterface, DEFAULT_GRANT_TYPES } from "../interfaces/oauth.interface";
20
+ import {
21
+ OAuthClientCreateRequest,
22
+ OAuthClientInterface,
23
+ OAuthScopeInfo,
24
+ DEFAULT_GRANT_TYPES,
25
+ } from "../interfaces/oauth.interface";
21
26
 
22
27
  export interface OAuthClientFormProps {
23
28
  /** Existing client for edit mode (undefined = create mode) */
@@ -28,6 +33,10 @@ export interface OAuthClientFormProps {
28
33
  onCancel: () => void;
29
34
  /** Whether form is submitting */
30
35
  isLoading?: boolean;
36
+ /** Scopes the app offers in the selector (defaults to AVAILABLE_OAUTH_SCOPES) */
37
+ availableScopes?: OAuthScopeInfo[];
38
+ /** Scopes preselected in create mode (edit mode uses client.allowedScopes) */
39
+ defaultScopes?: string[];
31
40
  }
32
41
 
33
42
  interface FormState {
@@ -47,14 +56,21 @@ interface FormErrors {
47
56
  /**
48
57
  * Form for creating or editing an OAuth client
49
58
  */
50
- export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false }: OAuthClientFormProps) {
59
+ export function OAuthClientForm({
60
+ client,
61
+ onSubmit,
62
+ onCancel,
63
+ isLoading = false,
64
+ availableScopes,
65
+ defaultScopes = [],
66
+ }: OAuthClientFormProps) {
51
67
  const isEditMode = !!client;
52
68
 
53
69
  const [formState, setFormState] = useState<FormState>({
54
70
  name: client?.name || "",
55
71
  description: client?.description || "",
56
72
  redirectUris: client?.redirectUris?.length ? client.redirectUris : [""],
57
- allowedScopes: client?.allowedScopes || [],
73
+ allowedScopes: client ? client.allowedScopes || [] : defaultScopes,
58
74
  isConfidential: client?.isConfidential ?? true,
59
75
  });
60
76
 
@@ -117,7 +133,7 @@ export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false
117
133
  id="name"
118
134
  value={formState.name}
119
135
  onChange={(e) => setFormState((s) => ({ ...s, name: e.target.value }))}
120
- placeholder="My Lightroom Plugin"
136
+ placeholder="My Application"
121
137
  disabled={isLoading}
122
138
  className={errors.name ? "border-destructive" : ""}
123
139
  />
@@ -149,6 +165,7 @@ export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false
149
165
  <OAuthScopeSelector
150
166
  value={formState.allowedScopes}
151
167
  onChange={(scopes) => setFormState((s) => ({ ...s, allowedScopes: scopes }))}
168
+ availableScopes={availableScopes}
152
169
  error={errors.allowedScopes}
153
170
  disabled={isLoading}
154
171
  />