@carlonicora/nextjs-jsonapi 1.77.3 → 1.79.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 (146) hide show
  1. package/dist/AssistantInterface-BYgI5z1-.d.mts +12 -0
  2. package/dist/AssistantInterface-DfDcz0gJ.d.ts +12 -0
  3. package/dist/AssistantMessageInterface-DWnbd6J7.d.ts +36 -0
  4. package/dist/AssistantMessageInterface-Mla6kgPe.d.mts +36 -0
  5. package/dist/{AuthComponent-Blbs06ud.d.ts → AuthComponent-B6DIk8Vf.d.ts} +1 -1
  6. package/dist/{AuthComponent-huIaK5rm.d.mts → AuthComponent-BKI0ZbtD.d.mts} +1 -1
  7. package/dist/{BlockNoteEditor-7HAAXN3H.mjs → BlockNoteEditor-6CBDTVKV.mjs} +4 -4
  8. package/dist/{BlockNoteEditor-UB7T7V67.js → BlockNoteEditor-EH4HWI7H.js} +14 -14
  9. package/dist/{BlockNoteEditor-UB7T7V67.js.map → BlockNoteEditor-EH4HWI7H.js.map} +1 -1
  10. package/dist/RbacTypes-BTbr27Ew.d.mts +43 -0
  11. package/dist/RbacTypes-BTbr27Ew.d.ts +43 -0
  12. package/dist/{auth.interface-CQJ6A2Cj.d.ts → auth.interface-BBUgMZzs.d.ts} +1 -1
  13. package/dist/{auth.interface-Bdq7-8iV.d.mts → auth.interface-XYEREOD6.d.mts} +1 -1
  14. package/dist/billing/index.js +346 -346
  15. package/dist/billing/index.mjs +3 -3
  16. package/dist/{chunk-FKLP4NED.js → chunk-5IEWLLLD.js} +379 -18
  17. package/dist/chunk-5IEWLLLD.js.map +1 -0
  18. package/dist/{chunk-XI35ALWY.mjs → chunk-BKM5U3DE.mjs} +362 -1
  19. package/dist/chunk-BKM5U3DE.mjs.map +1 -0
  20. package/dist/{chunk-F44ET4AC.mjs → chunk-ENRSFVOS.mjs} +2657 -2264
  21. package/dist/chunk-ENRSFVOS.mjs.map +1 -0
  22. package/dist/{chunk-JOJZRGZL.mjs → chunk-MEWXQEVE.mjs} +38 -29
  23. package/dist/{chunk-JOJZRGZL.mjs.map → chunk-MEWXQEVE.mjs.map} +1 -1
  24. package/dist/{chunk-OTZEXASK.js → chunk-TWDSDTHU.js} +39 -30
  25. package/dist/chunk-TWDSDTHU.js.map +1 -0
  26. package/dist/{chunk-CV7UOUKQ.js → chunk-ZDP3MBUI.js} +1813 -1420
  27. package/dist/chunk-ZDP3MBUI.js.map +1 -0
  28. package/dist/client/index.d.mts +6 -24
  29. package/dist/client/index.d.ts +6 -24
  30. package/dist/client/index.js +4 -10
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/index.mjs +3 -9
  33. package/dist/components/index.d.mts +51 -34
  34. package/dist/components/index.d.ts +51 -34
  35. package/dist/components/index.js +4 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/index.mjs +9 -9
  38. package/dist/{config-B3jKt9P7.d.ts → config-B5oBQVEA.d.ts} +1 -1
  39. package/dist/{config-DkHF61xA.d.mts → config-Bx_uh22h.d.mts} +1 -1
  40. package/dist/contexts/index.d.mts +65 -4
  41. package/dist/contexts/index.d.ts +65 -4
  42. package/dist/contexts/index.js +12 -4
  43. package/dist/contexts/index.js.map +1 -1
  44. package/dist/contexts/index.mjs +11 -3
  45. package/dist/core/index.d.mts +126 -11
  46. package/dist/core/index.d.ts +126 -11
  47. package/dist/core/index.js +16 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +15 -1
  50. package/dist/index.d.mts +118 -20
  51. package/dist/index.d.ts +118 -20
  52. package/dist/index.js +19 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +18 -2
  55. package/dist/{notification.interface-DG6obXUH.d.mts → notification.interface-DLZGtV7Z.d.mts} +1 -1
  56. package/dist/{notification.interface-DcSuc9CL.d.ts → notification.interface-aLEJbA_g.d.ts} +1 -1
  57. package/dist/{s3.service-DGilbikH.d.mts → s3.service-CVgLWaDc.d.mts} +2 -2
  58. package/dist/{s3.service-DjwEQJPe.d.ts → s3.service-SLlX0Zbz.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/useDataListRetriever-BqJSFBck.d.mts +33 -0
  64. package/dist/useDataListRetriever-BqJSFBck.d.ts +33 -0
  65. package/dist/{useSocket-CmzVtg32.d.mts → useSocket-BkxHHujj.d.mts} +1 -1
  66. package/dist/{useSocket-8eUtnL7J.d.ts → useSocket-CMDjWFYm.d.ts} +1 -1
  67. package/package.json +1 -1
  68. package/src/client/index.ts +0 -4
  69. package/src/components/index.ts +2 -3
  70. package/src/contexts/index.ts +2 -0
  71. package/src/core/index.ts +4 -0
  72. package/src/core/registry/ModuleRegistry.ts +10 -0
  73. package/src/features/assistant/AssistantModule.ts +19 -0
  74. package/src/features/assistant/components/containers/AssistantContainer.tsx +56 -0
  75. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +101 -0
  76. package/src/features/assistant/components/index.ts +1 -0
  77. package/src/features/assistant/components/parts/AssistantComposer.tsx +56 -0
  78. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +47 -0
  79. package/src/features/assistant/components/parts/AssistantSidebar.tsx +64 -0
  80. package/src/features/assistant/components/parts/AssistantStatusLine.tsx +19 -0
  81. package/src/features/assistant/components/parts/AssistantThread.tsx +36 -0
  82. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +91 -0
  83. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +32 -0
  84. package/src/features/assistant/components/parts/__tests__/AssistantEmptyState.spec.tsx +27 -0
  85. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +58 -0
  86. package/src/features/assistant/components/parts/__tests__/AssistantStatusLine.spec.tsx +19 -0
  87. package/src/features/assistant/components/parts/__tests__/AssistantThread.spec.tsx +39 -0
  88. package/src/features/assistant/components/parts/__tests__/AssistantThreadHeader.spec.tsx +67 -0
  89. package/src/features/assistant/contexts/AssistantContext.tsx +255 -0
  90. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +375 -0
  91. package/src/features/assistant/data/Assistant.ts +37 -0
  92. package/src/features/assistant/data/AssistantInterface.ts +11 -0
  93. package/src/features/assistant/data/AssistantService.ts +79 -0
  94. package/src/features/assistant/data/index.ts +3 -0
  95. package/src/features/assistant/index.ts +2 -0
  96. package/src/features/assistant/utils/__tests__/groupThreadsByBucket.spec.ts +24 -0
  97. package/src/features/assistant/utils/__tests__/resolveReferenceableModules.spec.ts +92 -0
  98. package/src/features/assistant/utils/groupThreadsByBucket.ts +26 -0
  99. package/src/features/assistant/utils/resolveReferenceableModules.ts +14 -0
  100. package/src/features/assistant-message/AssistantMessageModule.ts +28 -0
  101. package/src/features/assistant-message/components/MessageItem.tsx +60 -0
  102. package/src/features/assistant-message/components/MessageList.tsx +38 -0
  103. package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +108 -0
  104. package/src/features/assistant-message/components/index.ts +2 -0
  105. package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +46 -0
  106. package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +52 -0
  107. package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +59 -0
  108. package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +29 -0
  109. package/src/features/assistant-message/data/AssistantMessage.ts +95 -0
  110. package/src/features/assistant-message/data/AssistantMessageInterface.ts +21 -0
  111. package/src/features/assistant-message/data/AssistantMessageService.ts +40 -0
  112. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +158 -0
  113. package/src/features/assistant-message/data/index.ts +3 -0
  114. package/src/features/assistant-message/index.ts +2 -0
  115. package/src/features/rbac/components/RbacContainer.tsx +318 -49
  116. package/src/features/rbac/components/RbacPermissionPicker.tsx +144 -121
  117. package/src/features/rbac/contexts/RbacContext.tsx +209 -0
  118. package/src/features/rbac/contexts/index.ts +1 -0
  119. package/src/features/rbac/data/RbacMatrixModel.ts +84 -0
  120. package/src/features/rbac/data/RbacService.ts +61 -33
  121. package/src/features/rbac/data/RbacTypes.ts +28 -0
  122. package/src/features/rbac/data/index.ts +1 -0
  123. package/src/features/rbac/index.ts +1 -10
  124. package/src/features/rbac/rbac.module.ts +13 -0
  125. package/src/features/user/contexts/CurrentUserContext.tsx +5 -13
  126. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +141 -0
  127. package/src/index.ts +4 -0
  128. package/dist/HowToInterface-BKhnkzBp.d.ts +0 -17
  129. package/dist/HowToInterface-Cj8OuQFf.d.mts +0 -17
  130. package/dist/ModulePathsInterface-BrdqgteS.d.mts +0 -31
  131. package/dist/ModulePathsInterface-DJKs7s_s.d.ts +0 -31
  132. package/dist/chunk-CV7UOUKQ.js.map +0 -1
  133. package/dist/chunk-F44ET4AC.mjs.map +0 -1
  134. package/dist/chunk-FKLP4NED.js.map +0 -1
  135. package/dist/chunk-OTZEXASK.js.map +0 -1
  136. package/dist/chunk-XI35ALWY.mjs.map +0 -1
  137. package/dist/useRbacState-C88O-5L8.d.ts +0 -77
  138. package/dist/useRbacState-mqYiRp3J.d.mts +0 -77
  139. package/src/features/rbac/components/RbacFeatureSection.tsx +0 -66
  140. package/src/features/rbac/components/RbacModuleTable.tsx +0 -121
  141. package/src/features/rbac/components/RbacToolbar.tsx +0 -40
  142. package/src/features/rbac/hooks/useRbacState.test.ts +0 -180
  143. package/src/features/rbac/hooks/useRbacState.ts +0 -319
  144. package/src/features/rbac/utils/RbacMigrationGenerator.test.ts +0 -124
  145. package/src/features/rbac/utils/RbacMigrationGenerator.ts +0 -184
  146. /package/dist/{BlockNoteEditor-7HAAXN3H.mjs.map → BlockNoteEditor-6CBDTVKV.mjs.map} +0 -0
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useTranslations } from "next-intl";
5
+ import type { AssistantInterface } from "../../data/AssistantInterface";
6
+ import {
7
+ Button,
8
+ Input,
9
+ Popover,
10
+ PopoverTrigger,
11
+ PopoverContent,
12
+ Dialog,
13
+ DialogTrigger,
14
+ DialogContent,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogFooter,
18
+ } from "../../../../shadcnui";
19
+
20
+ interface Props {
21
+ assistant: AssistantInterface;
22
+ onRename: (title: string) => Promise<void>;
23
+ onDelete: () => Promise<void>;
24
+ }
25
+
26
+ export function AssistantThreadHeader({ assistant, onRename, onDelete }: Props) {
27
+ const t = useTranslations();
28
+ const [renameValue, setRenameValue] = useState(assistant.title);
29
+ const [renameOpen, setRenameOpen] = useState(false);
30
+ const [deleteOpen, setDeleteOpen] = useState(false);
31
+
32
+ const handleRename = async () => {
33
+ const trimmed = renameValue.trim() || assistant.title;
34
+ await onRename(trimmed);
35
+ setRenameOpen(false);
36
+ };
37
+
38
+ const handleDelete = async () => {
39
+ await onDelete();
40
+ setDeleteOpen(false);
41
+ };
42
+
43
+ return (
44
+ <div className="flex items-center justify-between border-b px-5 py-3">
45
+ <div className="text-foreground text-base font-semibold">{assistant.title}</div>
46
+ <div className="flex items-center gap-2">
47
+ <Popover open={renameOpen} onOpenChange={setRenameOpen}>
48
+ <PopoverTrigger
49
+ render={
50
+ <Button variant="outline" size="sm">
51
+ {t("features.assistant.rename")}
52
+ </Button>
53
+ }
54
+ />
55
+ <PopoverContent className="flex flex-col gap-2 p-3">
56
+ <Input
57
+ value={renameValue}
58
+ onChange={(e) => setRenameValue(e.target.value)}
59
+ placeholder={t("features.assistant.rename_placeholder")}
60
+ />
61
+ <Button onClick={handleRename} size="sm">
62
+ {t("ui.buttons.save")}
63
+ </Button>
64
+ </PopoverContent>
65
+ </Popover>
66
+ <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
67
+ <DialogTrigger
68
+ render={
69
+ <Button variant="outline" size="sm" className="text-destructive">
70
+ {t("features.assistant.delete")}
71
+ </Button>
72
+ }
73
+ />
74
+ <DialogContent>
75
+ <DialogHeader>
76
+ <DialogTitle>{t("features.assistant.delete_confirm")}</DialogTitle>
77
+ </DialogHeader>
78
+ <DialogFooter>
79
+ <Button variant="outline" onClick={() => setDeleteOpen(false)}>
80
+ {t("ui.buttons.cancel")}
81
+ </Button>
82
+ <Button variant="destructive" onClick={handleDelete}>
83
+ {t("ui.buttons.confirm")}
84
+ </Button>
85
+ </DialogFooter>
86
+ </DialogContent>
87
+ </Dialog>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { AssistantComposer } from "../AssistantComposer";
5
+
6
+ describe("AssistantComposer", () => {
7
+ it("Enter submits non-empty content and clears the textarea", async () => {
8
+ const onSend = vi.fn().mockResolvedValue(undefined);
9
+ render(<AssistantComposer onSend={onSend} />);
10
+ const textarea = screen.getByPlaceholderText("features.assistant.composer_placeholder");
11
+ await userEvent.type(textarea, "hello");
12
+ await userEvent.keyboard("{Enter}");
13
+ expect(onSend).toHaveBeenCalledWith("hello");
14
+ expect((textarea as HTMLTextAreaElement).value).toBe("");
15
+ });
16
+
17
+ it("Shift+Enter does NOT submit", async () => {
18
+ const onSend = vi.fn();
19
+ render(<AssistantComposer onSend={onSend} />);
20
+ const textarea = screen.getByPlaceholderText("features.assistant.composer_placeholder");
21
+ await userEvent.type(textarea, "multi");
22
+ await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
23
+ expect(onSend).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("Send is disabled on empty/whitespace", () => {
27
+ render(<AssistantComposer onSend={vi.fn()} />);
28
+ // "save" appears inside the ui.buttons.save key → button text matches /save/i
29
+ const send = screen.getByRole("button", { name: /save/i });
30
+ expect(send).toBeDisabled();
31
+ });
32
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { AssistantEmptyState } from "../AssistantEmptyState";
5
+
6
+ describe("AssistantEmptyState", () => {
7
+ it("renders the heading + 4 starter prompts", () => {
8
+ render(<AssistantEmptyState onSend={vi.fn()} />);
9
+ expect(screen.getByRole("heading", { name: "features.assistant.empty_state.title" })).toBeInTheDocument();
10
+ expect(screen.getByText("features.assistant.empty_state.subtitle")).toBeInTheDocument();
11
+ expect(screen.getByText("features.assistant.starters.a")).toBeInTheDocument();
12
+ expect(screen.getByText("features.assistant.starters.b")).toBeInTheDocument();
13
+ expect(screen.getByText("features.assistant.starters.c")).toBeInTheDocument();
14
+ expect(screen.getByText("features.assistant.starters.d")).toBeInTheDocument();
15
+ });
16
+
17
+ it("clicking a starter fills the composer (does NOT auto-send)", async () => {
18
+ const onSend = vi.fn();
19
+ render(<AssistantEmptyState onSend={onSend} />);
20
+ const firstStarter = screen.getByText("features.assistant.starters.a");
21
+ await userEvent.click(firstStarter);
22
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
23
+ // The starter key text itself is what the composer should hold (since useTranslations returns keys)
24
+ expect(textarea.value).toBe("features.assistant.starters.a");
25
+ expect(onSend).not.toHaveBeenCalled();
26
+ });
27
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import type { AssistantInterface } from "../../../data/AssistantInterface";
5
+ import { AssistantSidebar } from "../AssistantSidebar";
6
+
7
+ function buildAssistantStub({
8
+ id,
9
+ title = "Stub",
10
+ updatedAt = new Date(),
11
+ }: {
12
+ id: string;
13
+ title?: string;
14
+ updatedAt?: Date;
15
+ }): AssistantInterface {
16
+ return {
17
+ id,
18
+ title,
19
+ messageCount: 0,
20
+ type: "assistants",
21
+ createdAt: updatedAt,
22
+ updatedAt,
23
+ } as unknown as AssistantInterface;
24
+ }
25
+
26
+ describe("AssistantSidebar", () => {
27
+ describe("with fake timers (bucket grouping)", () => {
28
+ beforeEach(() => vi.useFakeTimers().setSystemTime(new Date("2026-04-22T12:00:00Z")));
29
+ afterEach(() => vi.useRealTimers());
30
+
31
+ it("renders '+ New assistant' button and groups threads by bucket", () => {
32
+ const threads = [
33
+ buildAssistantStub({ id: "a1", title: "Today item", updatedAt: new Date("2026-04-22T09:00:00Z") }),
34
+ buildAssistantStub({ id: "a2", title: "Week item", updatedAt: new Date("2026-04-20T09:00:00Z") }),
35
+ buildAssistantStub({ id: "a3", title: "Old item", updatedAt: new Date("2026-03-01T09:00:00Z") }),
36
+ ];
37
+ render(<AssistantSidebar threads={threads} activeId="a1" onSelect={vi.fn()} onNew={vi.fn()} />);
38
+ // New button: translation key includes "new" — matches /new/
39
+ expect(screen.getByRole("button", { name: /features\.assistant\.new/ })).toBeInTheDocument();
40
+ expect(screen.getByText("features.assistant.bucket_today")).toBeInTheDocument();
41
+ expect(screen.getByText("features.assistant.bucket_week")).toBeInTheDocument();
42
+ expect(screen.getByText("features.assistant.bucket_earlier")).toBeInTheDocument();
43
+ });
44
+
45
+ it("empty threads: shows empty_sidebar key", () => {
46
+ render(<AssistantSidebar threads={[]} onSelect={vi.fn()} onNew={vi.fn()} />);
47
+ expect(screen.getByText("features.assistant.empty_sidebar")).toBeInTheDocument();
48
+ });
49
+ });
50
+
51
+ it("clicking a thread calls onSelect with its id", async () => {
52
+ const onSelect = vi.fn();
53
+ const threads = [buildAssistantStub({ id: "a1", title: "T", updatedAt: new Date("2026-04-22T09:00:00Z") })];
54
+ render(<AssistantSidebar threads={threads} onSelect={onSelect} onNew={vi.fn()} />);
55
+ await userEvent.click(screen.getByRole("button", { name: /^T$/ }));
56
+ expect(onSelect).toHaveBeenCalledWith("a1");
57
+ });
58
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { AssistantStatusLine } from "../AssistantStatusLine";
4
+
5
+ // next-intl is globally mocked in vitest.setup.ts:
6
+ // useTranslations: () => (key) => key
7
+ // So t("features.assistant.thinking") returns the key string.
8
+
9
+ describe("AssistantStatusLine", () => {
10
+ it("renders the status when provided", () => {
11
+ render(<AssistantStatusLine status="Reading orders · abc" />);
12
+ expect(screen.getByText("Reading orders · abc")).toBeInTheDocument();
13
+ });
14
+
15
+ it("falls back to the translation key when no status", () => {
16
+ render(<AssistantStatusLine />);
17
+ expect(screen.getByText("features.assistant.thinking")).toBeInTheDocument();
18
+ });
19
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import type { ApiDataInterface } from "../../../../../core";
4
+ import type { AssistantMessageInterface } from "../../../../assistant-message/data/AssistantMessageInterface";
5
+ import { AssistantThread } from "../AssistantThread";
6
+
7
+ beforeAll(() => {
8
+ // jsdom lacks scrollIntoView
9
+ Element.prototype.scrollIntoView = vi.fn();
10
+ });
11
+
12
+ function buildMessageStub(p: { role: "user" | "assistant"; content?: string }): AssistantMessageInterface {
13
+ return {
14
+ id: Math.random().toString(36).slice(2),
15
+ type: "assistant-messages",
16
+ role: p.role,
17
+ content: p.content ?? "",
18
+ position: 0,
19
+ references: [] as ApiDataInterface[],
20
+ suggestedQuestions: [] as string[],
21
+ createdAt: new Date(),
22
+ updatedAt: new Date(),
23
+ } as unknown as AssistantMessageInterface;
24
+ }
25
+
26
+ describe("AssistantThread", () => {
27
+ it("renders message list + status line when sending", () => {
28
+ const msgs = [buildMessageStub({ role: "user", content: "hi" })];
29
+ render(<AssistantThread messages={msgs} sending={true} status="Searching..." onSelectFollowUp={vi.fn()} />);
30
+ expect(screen.getByText("hi")).toBeInTheDocument();
31
+ expect(screen.getByText(/Searching/)).toBeInTheDocument();
32
+ });
33
+
34
+ it("hides status line when not sending", () => {
35
+ render(<AssistantThread messages={[]} sending={false} status={undefined} onSelectFollowUp={vi.fn()} />);
36
+ // When not sending, AssistantStatusLine should not render → the default "thinking" key should be absent
37
+ expect(screen.queryByText("features.assistant.thinking")).not.toBeInTheDocument();
38
+ });
39
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import type { AssistantInterface } from "../../../data/AssistantInterface";
5
+ import { AssistantThreadHeader } from "../AssistantThreadHeader";
6
+
7
+ function buildAssistantStub({ id, title = "Stub" }: { id: string; title?: string }): AssistantInterface {
8
+ return {
9
+ id,
10
+ title,
11
+ messageCount: 0,
12
+ type: "assistants",
13
+ createdAt: new Date(),
14
+ updatedAt: new Date(),
15
+ } as unknown as AssistantInterface;
16
+ }
17
+
18
+ describe("AssistantThreadHeader", () => {
19
+ it("renders title + rename + delete triggers", () => {
20
+ render(
21
+ <AssistantThreadHeader
22
+ assistant={buildAssistantStub({ id: "a1", title: "Hello" })}
23
+ onRename={vi.fn()}
24
+ onDelete={vi.fn()}
25
+ />,
26
+ );
27
+ expect(screen.getByText("Hello")).toBeInTheDocument();
28
+ // Translation keys are passed through, so /rename/ and /delete/ match keys like "features.assistant.rename"
29
+ expect(screen.getByRole("button", { name: /rename/ })).toBeInTheDocument();
30
+ expect(screen.getByRole("button", { name: /delete/ })).toBeInTheDocument();
31
+ });
32
+
33
+ it("rename popover submits new title and closes", async () => {
34
+ const onRename = vi.fn().mockResolvedValue(undefined);
35
+ render(
36
+ <AssistantThreadHeader
37
+ assistant={buildAssistantStub({ id: "a1", title: "Old" })}
38
+ onRename={onRename}
39
+ onDelete={vi.fn()}
40
+ />,
41
+ );
42
+ await userEvent.click(screen.getByRole("button", { name: /rename/ }));
43
+ const input = await screen.findByRole("textbox");
44
+ await userEvent.clear(input);
45
+ await userEvent.type(input, "New");
46
+ // "save" appears in the ui.buttons.save key → matches /save/
47
+ await userEvent.click(screen.getByRole("button", { name: /save/ }));
48
+ expect(onRename).toHaveBeenCalledWith("New");
49
+ });
50
+
51
+ it("delete confirm calls onDelete", async () => {
52
+ const onDelete = vi.fn().mockResolvedValue(undefined);
53
+ render(
54
+ <AssistantThreadHeader
55
+ assistant={buildAssistantStub({ id: "a1", title: "X" })}
56
+ onRename={vi.fn()}
57
+ onDelete={onDelete}
58
+ />,
59
+ );
60
+ // open the dialog
61
+ await userEvent.click(screen.getByRole("button", { name: /delete/ }));
62
+ // "confirm" appears in the ui.buttons.confirm key
63
+ const confirmBtn = await screen.findByRole("button", { name: /confirm/ });
64
+ await userEvent.click(confirmBtn);
65
+ expect(onDelete).toHaveBeenCalled();
66
+ });
67
+ });
@@ -0,0 +1,255 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
+ import { SharedProvider } from "../../../contexts";
6
+ import { useSocketContext } from "../../../contexts/SocketContext";
7
+ import { BreadcrumbItemData, JsonApiHydratedDataInterface, Modules, rehydrate, rehydrateList } from "../../../core";
8
+ import { usePageUrlGenerator } from "../../../hooks";
9
+ import { AssistantMessage } from "../../assistant-message/data/AssistantMessage";
10
+ import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
11
+ import { AssistantMessageService } from "../../assistant-message/data/AssistantMessageService";
12
+ import type { AssistantInterface } from "../data/AssistantInterface";
13
+ import { AssistantService } from "../data/AssistantService";
14
+
15
+ interface AssistantContextValue {
16
+ assistant?: AssistantInterface;
17
+ messages: AssistantMessageInterface[];
18
+ threads: AssistantInterface[];
19
+ threadsLoading: boolean;
20
+ sending: boolean;
21
+ status?: string;
22
+ failedMessageIds: Set<string>;
23
+ sendMessage(content: string): Promise<void>;
24
+ retrySend(tempId: string): Promise<void>;
25
+ selectThread(id: string): Promise<void>;
26
+ startNew(): void;
27
+ renameThread(id: string, title: string): Promise<void>;
28
+ deleteThread(id: string): Promise<void>;
29
+ }
30
+
31
+ const AssistantContext = createContext<AssistantContextValue | undefined>(undefined);
32
+
33
+ interface Props {
34
+ children: React.ReactNode;
35
+ dehydratedAssistant?: JsonApiHydratedDataInterface;
36
+ dehydratedMessages?: JsonApiHydratedDataInterface[];
37
+ }
38
+
39
+ function stripOptimistic(list: AssistantMessageInterface[]): AssistantMessageInterface[] {
40
+ return list.filter((m) => !m.id.startsWith("tmp-"));
41
+ }
42
+
43
+ function nextPosition(list: AssistantMessageInterface[]): number {
44
+ return list.reduce((max, m) => (m.position > max ? m.position : max), 0) + 1;
45
+ }
46
+
47
+ function withPatchedTitle(source: AssistantInterface, title: string): AssistantInterface {
48
+ const dehydrated = source.dehydrate();
49
+ return rehydrate<AssistantInterface>(Modules.Assistant, {
50
+ jsonApi: {
51
+ ...dehydrated.jsonApi,
52
+ attributes: { ...(dehydrated.jsonApi?.attributes ?? {}), title },
53
+ },
54
+ included: dehydrated.included,
55
+ });
56
+ }
57
+
58
+ export function AssistantProvider({ children, dehydratedAssistant, dehydratedMessages }: Props) {
59
+ const t = useTranslations();
60
+ const generateUrl = usePageUrlGenerator();
61
+
62
+ const [assistant, setAssistant] = useState<AssistantInterface | undefined>(() =>
63
+ dehydratedAssistant ? rehydrate<AssistantInterface>(Modules.Assistant, dehydratedAssistant) : undefined,
64
+ );
65
+ const [messages, setMessages] = useState<AssistantMessageInterface[]>(() =>
66
+ dehydratedMessages ? rehydrateList<AssistantMessageInterface>(Modules.AssistantMessage, dehydratedMessages) : [],
67
+ );
68
+ const [threads, setThreads] = useState<AssistantInterface[]>([]);
69
+ const [threadsLoading, setThreadsLoading] = useState<boolean>(true);
70
+ const [sending, setSending] = useState<boolean>(false);
71
+ const [status, setStatus] = useState<string | undefined>(undefined);
72
+ const [failedMessageIds, setFailedMessageIds] = useState<Set<string>>(() => new Set());
73
+ const { socket } = useSocketContext();
74
+
75
+ const sendMessage = useCallback(
76
+ async (content: string) => {
77
+ const trimmed = content.trim();
78
+ if (!trimmed) return;
79
+
80
+ const optimistic = AssistantMessage.buildOptimistic({
81
+ content: trimmed,
82
+ assistantId: assistant?.id,
83
+ position: nextPosition(messages),
84
+ });
85
+ setMessages((prev) => [...prev, optimistic]);
86
+ setSending(true);
87
+
88
+ const handler = (payload: { assistantId?: string; status?: string }) => {
89
+ if (!payload) return;
90
+ if (assistant && payload.assistantId && payload.assistantId !== assistant.id) return;
91
+ if (typeof payload.status === "string") setStatus(payload.status);
92
+ };
93
+ socket?.on("assistant:status", handler);
94
+
95
+ try {
96
+ if (!assistant) {
97
+ const created = await AssistantService.create({ firstMessage: trimmed });
98
+ const msgs = await AssistantMessageService.findByAssistant({ assistantId: created.id });
99
+ setAssistant(created);
100
+ setMessages(msgs);
101
+ setThreads((prev) => [created, ...prev]);
102
+ if (typeof window !== "undefined") {
103
+ window.history.replaceState(null, "", `/assistants/${created.id}`);
104
+ }
105
+ } else {
106
+ const result = await AssistantService.appendMessage({
107
+ assistantId: assistant.id,
108
+ content: trimmed,
109
+ });
110
+ setMessages((prev) => [...stripOptimistic(prev), ...result]);
111
+ }
112
+ } catch {
113
+ setFailedMessageIds((prev) => {
114
+ const next = new Set(prev);
115
+ next.add(optimistic.id);
116
+ return next;
117
+ });
118
+ } finally {
119
+ socket?.off("assistant:status", handler);
120
+ setSending(false);
121
+ setStatus(undefined);
122
+ }
123
+ },
124
+ [assistant, messages, socket],
125
+ );
126
+
127
+ const retrySend = useCallback(
128
+ async (tempId: string) => {
129
+ const failed = messages.find((m) => m.id === tempId);
130
+ if (!failed) return;
131
+ const content = failed.content;
132
+
133
+ setMessages((prev) => prev.filter((m) => m.id !== tempId));
134
+ setFailedMessageIds((prev) => {
135
+ const next = new Set(prev);
136
+ next.delete(tempId);
137
+ return next;
138
+ });
139
+
140
+ await sendMessage(content);
141
+ },
142
+ [messages, sendMessage],
143
+ );
144
+
145
+ const selectThread = useCallback(async (id: string) => {
146
+ const [target, msgs] = await Promise.all([
147
+ AssistantService.findOne({ id }),
148
+ AssistantMessageService.findByAssistant({ assistantId: id }),
149
+ ]);
150
+ setAssistant(target);
151
+ setMessages(msgs);
152
+ if (typeof window !== "undefined") {
153
+ window.history.replaceState(null, "", `/assistants/${id}`);
154
+ }
155
+ }, []);
156
+
157
+ const renameThread = useCallback(async (id: string, title: string) => {
158
+ await AssistantService.rename({ id, title });
159
+ setAssistant((prev) => (prev && prev.id === id ? withPatchedTitle(prev, title) : prev));
160
+ setThreads((prev) => prev.map((t) => (t.id === id ? withPatchedTitle(t, title) : t)));
161
+ }, []);
162
+
163
+ const startNew = useCallback(() => {
164
+ setAssistant(undefined);
165
+ setMessages([]);
166
+ setFailedMessageIds(new Set());
167
+ if (typeof window !== "undefined") {
168
+ window.history.replaceState(null, "", "/assistants");
169
+ }
170
+ }, []);
171
+
172
+ const deleteThread = useCallback(async (id: string) => {
173
+ await AssistantService.delete({ id });
174
+ setThreads((prev) => prev.filter((t) => t.id !== id));
175
+ setAssistant((prev) => {
176
+ if (prev?.id === id) {
177
+ setMessages([]);
178
+ return undefined;
179
+ }
180
+ return prev;
181
+ });
182
+ }, []);
183
+
184
+ useEffect(() => {
185
+ let cancelled = false;
186
+ (async () => {
187
+ setThreadsLoading(true);
188
+ try {
189
+ const loaded = await AssistantService.findMany();
190
+ if (!cancelled) setThreads(loaded);
191
+ } finally {
192
+ if (!cancelled) setThreadsLoading(false);
193
+ }
194
+ })();
195
+ return () => {
196
+ cancelled = true;
197
+ };
198
+ }, []);
199
+
200
+ const value = useMemo<AssistantContextValue>(
201
+ () => ({
202
+ assistant,
203
+ messages,
204
+ threads,
205
+ threadsLoading,
206
+ sending,
207
+ status,
208
+ failedMessageIds,
209
+ sendMessage,
210
+ retrySend,
211
+ selectThread,
212
+ startNew,
213
+ renameThread,
214
+ deleteThread,
215
+ }),
216
+ [
217
+ assistant,
218
+ messages,
219
+ threads,
220
+ threadsLoading,
221
+ sending,
222
+ status,
223
+ failedMessageIds,
224
+ sendMessage,
225
+ retrySend,
226
+ selectThread,
227
+ startNew,
228
+ renameThread,
229
+ deleteThread,
230
+ ],
231
+ );
232
+
233
+ const breadcrumbs: BreadcrumbItemData[] = [
234
+ {
235
+ name: t("entities.assistants", { count: 2 }),
236
+ href: generateUrl({ page: Modules.Assistant }),
237
+ },
238
+ ];
239
+
240
+ const title = {
241
+ type: t("entities.tasks", { count: 2 }),
242
+ };
243
+
244
+ return (
245
+ <AssistantContext.Provider value={value}>
246
+ <SharedProvider value={{ breadcrumbs, title }}>{children}</SharedProvider>
247
+ </AssistantContext.Provider>
248
+ );
249
+ }
250
+
251
+ export function useAssistantContext(): AssistantContextValue {
252
+ const ctx = useContext(AssistantContext);
253
+ if (!ctx) throw new Error("useAssistantContext must be used within AssistantProvider");
254
+ return ctx;
255
+ }