@carlonicora/nextjs-jsonapi 3.23.0 → 3.24.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 (106) 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-BUSOUPQ7.js → BlockNoteEditor-7I7PL3KS.js} +19 -19
  4. package/dist/{BlockNoteEditor-BUSOUPQ7.js.map → BlockNoteEditor-7I7PL3KS.js.map} +1 -1
  5. package/dist/{BlockNoteEditor-W42IQTGL.mjs → BlockNoteEditor-BNS3U3CP.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-FQ23TZ3J.mjs → BlockNoteViewer-THOJS3OG.mjs} +2 -2
  7. package/dist/{BlockNoteViewer-MCX53AR4.js → BlockNoteViewer-UCWWHBTZ.js} +3 -3
  8. package/dist/{BlockNoteViewer-MCX53AR4.js.map → BlockNoteViewer-UCWWHBTZ.js.map} +1 -1
  9. package/dist/billing/index.d.mts +57 -2
  10. package/dist/billing/index.d.ts +57 -2
  11. package/dist/billing/index.js +406 -401
  12. package/dist/billing/index.js.map +1 -1
  13. package/dist/billing/index.mjs +8 -3
  14. package/dist/{chunk-GFLEFIAW.mjs → chunk-6R45U3GH.mjs} +1401 -1178
  15. package/dist/chunk-6R45U3GH.mjs.map +1 -0
  16. package/dist/{chunk-FXWO5J2R.js → chunk-7EFTSFZO.js} +7 -7
  17. package/dist/{chunk-FXWO5J2R.js.map → chunk-7EFTSFZO.js.map} +1 -1
  18. package/dist/{chunk-VR65NRE4.js → chunk-7XD2BJN6.js} +1338 -1115
  19. package/dist/chunk-7XD2BJN6.js.map +1 -0
  20. package/dist/{chunk-YCHPWXHD.js → chunk-WRMTBKSN.js} +44 -40
  21. package/dist/chunk-WRMTBKSN.js.map +1 -0
  22. package/dist/{chunk-JZUBFAAY.mjs → chunk-Z7KPVKWV.mjs} +2 -2
  23. package/dist/{chunk-O5MKEU7G.mjs → chunk-ZE5ZUY6Y.mjs} +44 -40
  24. package/dist/chunk-ZE5ZUY6Y.mjs.map +1 -0
  25. package/dist/client/index.d.mts +13 -2
  26. package/dist/client/index.d.ts +13 -2
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.d.mts +87 -28
  30. package/dist/components/index.d.ts +87 -28
  31. package/dist/components/index.js +8 -4
  32. package/dist/components/index.js.map +1 -1
  33. package/dist/components/index.mjs +7 -3
  34. package/dist/contexts/index.d.mts +11 -2
  35. package/dist/contexts/index.d.ts +11 -2
  36. package/dist/contexts/index.js +4 -4
  37. package/dist/contexts/index.mjs +3 -3
  38. package/dist/core/index.d.mts +29 -7
  39. package/dist/core/index.d.ts +29 -7
  40. package/dist/core/index.js +2 -2
  41. package/dist/core/index.mjs +1 -1
  42. package/dist/features/help/index.js +37 -37
  43. package/dist/features/help/index.mjs +3 -3
  44. package/dist/features/tokenusage/index.js +101 -101
  45. package/dist/features/tokenusage/index.mjs +3 -3
  46. package/dist/index.d.mts +3 -4
  47. package/dist/index.d.ts +3 -4
  48. package/dist/index.js +3 -3
  49. package/dist/index.mjs +2 -2
  50. package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
  51. package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
  52. package/dist/{s3.service-DJHqEZAp.d.ts → s3.service-Bqr-fQmi.d.ts} +1 -0
  53. package/dist/{s3.service-DaRFLnrX.d.mts → s3.service-Buiy1ncj.d.mts} +1 -0
  54. package/dist/server/index.d.mts +1 -1
  55. package/dist/server/index.d.ts +1 -1
  56. package/dist/server/index.js +3 -3
  57. package/dist/server/index.mjs +1 -1
  58. package/package.json +1 -1
  59. package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
  60. package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
  61. package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
  62. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
  63. package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
  64. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
  65. package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
  66. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
  67. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
  68. package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
  69. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
  70. package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
  71. package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
  72. package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
  73. package/src/features/assistant-message/components/index.ts +1 -0
  74. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
  75. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
  76. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
  77. package/src/features/billing/stripe-customer/data/stripe-customer.service.ts +5 -0
  78. package/src/features/billing/stripe-subscription/components/index.ts +1 -0
  79. package/src/features/notification/contexts/NotificationContext.tsx +5 -2
  80. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +32 -0
  81. package/src/features/notification/data/notification.service.ts +6 -1
  82. package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
  83. package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
  84. package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
  85. package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
  86. package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
  87. package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
  88. package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
  89. package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
  90. package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
  91. package/src/features/oauth/components/consent/index.ts +1 -0
  92. package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
  93. package/src/features/oauth/data/oauth.service.ts +3 -13
  94. package/src/features/oauth/data/oauth.ts +30 -1
  95. package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
  96. package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
  97. package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
  98. package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
  99. package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
  100. package/dist/chunk-GFLEFIAW.mjs.map +0 -1
  101. package/dist/chunk-O5MKEU7G.mjs.map +0 -1
  102. package/dist/chunk-VR65NRE4.js.map +0 -1
  103. package/dist/chunk-YCHPWXHD.js.map +0 -1
  104. /package/dist/{BlockNoteEditor-W42IQTGL.mjs.map → BlockNoteEditor-BNS3U3CP.mjs.map} +0 -0
  105. /package/dist/{BlockNoteViewer-FQ23TZ3J.mjs.map → BlockNoteViewer-THOJS3OG.mjs.map} +0 -0
  106. /package/dist/{chunk-JZUBFAAY.mjs.map → chunk-Z7KPVKWV.mjs.map} +0 -0
@@ -3,7 +3,23 @@ vi.mock("../../../../../contexts/SocketContext", () => ({
3
3
  }));
4
4
 
5
5
  vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
6
- RoundPageContainer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
6
+ RoundPageContainer: ({
7
+ children,
8
+ details,
9
+ detailsTitle,
10
+ }: {
11
+ children: React.ReactNode;
12
+ details?: React.ReactNode;
13
+ detailsTitle?: React.ReactNode;
14
+ }) => (
15
+ <div data-testid="round-page-container">
16
+ {children}
17
+ <div data-testid="round-page-details">
18
+ {detailsTitle}
19
+ {details}
20
+ </div>
21
+ </div>
22
+ ),
7
23
  }));
8
24
 
9
25
  import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
@@ -13,11 +29,40 @@ import { AssistantService } from "../../../data/AssistantService";
13
29
  import type { JsonApiHydratedDataInterface } from "../../../../../core";
14
30
  import { AssistantContainer } from "../AssistantContainer";
15
31
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
32
+ import { configureI18n } from "../../../../../i18n";
16
33
  import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
17
34
  import { Assistant } from "../../../data/Assistant";
18
35
  import { AssistantMessage } from "../../../../assistant-message/data/AssistantMessage";
19
36
 
20
37
  beforeAll(() => {
38
+ // The package Link resolves its inner component at runtime and throws if i18n
39
+ // was never configured (src/i18n/config.ts).
40
+ configureI18n({
41
+ useRouter: () => ({
42
+ push: vi.fn(),
43
+ replace: vi.fn(),
44
+ back: vi.fn(),
45
+ forward: vi.fn(),
46
+ refresh: vi.fn(),
47
+ prefetch: vi.fn(),
48
+ }),
49
+ useTranslations: () => (key: string) => key,
50
+ usePathname: () => "/",
51
+ Link: ({
52
+ href,
53
+ children,
54
+ prefetch: _prefetch,
55
+ ...rest
56
+ }: {
57
+ href: string;
58
+ children: React.ReactNode;
59
+ [key: string]: any;
60
+ }) => (
61
+ <a href={href} {...rest}>
62
+ {children}
63
+ </a>
64
+ ),
65
+ });
21
66
  Element.prototype.scrollIntoView = vi.fn();
22
67
  const assistantModule = { name: "assistants", model: Assistant } as any;
23
68
  const assistantMessageModule = { name: "assistant-messages", model: AssistantMessage } as any;
@@ -25,6 +70,7 @@ beforeAll(() => {
25
70
  DataClassRegistry.registerObjectClass(assistantMessageModule, AssistantMessage);
26
71
  ModuleRegistry.register("Assistant" as any, assistantModule as any);
27
72
  ModuleRegistry.register("AssistantMessage" as any, assistantMessageModule as any);
73
+ ModuleRegistry.register("Npc" as any, { name: "npcs", pageUrl: "/npcs" } as any);
28
74
  });
29
75
 
30
76
  beforeEach(() => {
@@ -48,6 +94,17 @@ function buildAssistantDehydrated({
48
94
  };
49
95
  }
50
96
 
97
+ function buildMessageDehydrated({ id, content }: { id: string; content: string }): JsonApiHydratedDataInterface {
98
+ return {
99
+ jsonApi: {
100
+ type: "assistant-messages",
101
+ id,
102
+ attributes: { role: "user", content, position: 1 },
103
+ },
104
+ included: [],
105
+ };
106
+ }
107
+
51
108
  describe("AssistantContainer", () => {
52
109
  it("shows empty state when no assistant is active", async () => {
53
110
  render(
@@ -98,4 +155,109 @@ describe("AssistantContainer", () => {
98
155
  // Status line is showing ("thinking…" translation key renders literally in tests).
99
156
  expect(screen.getByText("features.assistant.thinking")).toBeInTheDocument();
100
157
  });
158
+
159
+ it("passes renderMention through to messages", () => {
160
+ const active = buildAssistantDehydrated({ id: "a1", title: "T" });
161
+ const messages = [buildMessageDehydrated({ id: "m1", content: "Ask [X](mention://npcs/n1) about it." })];
162
+ render(
163
+ <AssistantProvider dehydratedAssistant={active} dehydratedMessages={messages}>
164
+ <AssistantContainer
165
+ renderMention={({ type, id, alias }) => <span data-testid="custom-mention">{`${type}:${id}:${alias}`}</span>}
166
+ />
167
+ </AssistantProvider>,
168
+ );
169
+ expect(screen.getByTestId("custom-mention")).toHaveTextContent("npcs:n1:X");
170
+ });
171
+
172
+ it("uses EntityMentionLink by default", () => {
173
+ const active = buildAssistantDehydrated({ id: "a1", title: "T" });
174
+ const messages = [buildMessageDehydrated({ id: "m1", content: "Ask [X](mention://npcs/n1) about it." })];
175
+ render(
176
+ <AssistantProvider dehydratedAssistant={active} dehydratedMessages={messages}>
177
+ <AssistantContainer />
178
+ </AssistantProvider>,
179
+ );
180
+ expect(screen.getByRole("link", { name: "X" })).toHaveAttribute("href", "/npcs/n1");
181
+ });
182
+
183
+ it("embedded renders without RoundPageContainer", async () => {
184
+ const { unmount } = render(
185
+ <AssistantProvider>
186
+ <AssistantContainer />
187
+ </AssistantProvider>,
188
+ );
189
+ expect(screen.getByTestId("round-page-container")).toBeInTheDocument();
190
+ unmount();
191
+
192
+ render(
193
+ <AssistantProvider>
194
+ <AssistantContainer embedded />
195
+ </AssistantProvider>,
196
+ );
197
+ await waitFor(() =>
198
+ expect(screen.getByRole("heading", { name: "features.assistant.empty_state.title" })).toBeInTheDocument(),
199
+ );
200
+ expect(screen.queryByTestId("round-page-container")).not.toBeInTheDocument();
201
+ });
202
+
203
+ it("renderScopePicker shows only before the first message", async () => {
204
+ AssistantService.create = vi.fn().mockImplementation(() => new Promise(() => {}));
205
+
206
+ render(
207
+ <AssistantProvider>
208
+ <AssistantContainer renderScopePicker={() => <div data-testid="scope-picker" />} />
209
+ </AssistantProvider>,
210
+ );
211
+ expect(screen.getByTestId("scope-picker")).toBeInTheDocument();
212
+
213
+ const textarea = await screen.findByRole("textbox");
214
+ const user = (await import("@testing-library/user-event")).default.setup();
215
+ await user.type(textarea, "first one");
216
+ await user.keyboard("{Enter}");
217
+
218
+ await waitFor(() => expect(screen.queryByTestId("scope-picker")).not.toBeInTheDocument());
219
+ });
220
+
221
+ it("shows the operator toggle by default", async () => {
222
+ render(
223
+ <AssistantProvider>
224
+ <AssistantContainer />
225
+ </AssistantProvider>,
226
+ );
227
+ await waitFor(() => expect(screen.getByText("features.assistant.operator_mode")).toBeInTheDocument());
228
+ });
229
+
230
+ it("showOperatorToggle={false} hides the operator toggle", async () => {
231
+ render(
232
+ <AssistantProvider>
233
+ <AssistantContainer showOperatorToggle={false} />
234
+ </AssistantProvider>,
235
+ );
236
+ await waitFor(() =>
237
+ expect(screen.getByRole("heading", { name: "features.assistant.empty_state.title" })).toBeInTheDocument(),
238
+ );
239
+ expect(screen.queryByText("features.assistant.operator_mode")).not.toBeInTheDocument();
240
+ });
241
+
242
+ it("page: thread list sits in the details panel, without its own new button", async () => {
243
+ AssistantService.findMany = vi.fn().mockResolvedValue([]);
244
+ render(
245
+ <AssistantProvider>
246
+ <AssistantContainer />
247
+ </AssistantProvider>,
248
+ );
249
+ const details = screen.getByTestId("round-page-details");
250
+ expect(details).toHaveTextContent("features.assistant.list_title");
251
+ await waitFor(() => expect(details).toHaveTextContent("features.assistant.empty_sidebar"));
252
+ expect(screen.queryByRole("button", { name: /features\.assistant\.new/ })).not.toBeInTheDocument();
253
+ });
254
+
255
+ it("embedded: keeps the sidebar with its new button", async () => {
256
+ render(
257
+ <AssistantProvider>
258
+ <AssistantContainer embedded />
259
+ </AssistantProvider>,
260
+ );
261
+ expect(screen.getByRole("button", { name: /features\.assistant\.new/ })).toBeInTheDocument();
262
+ });
101
263
  });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, type KeyboardEvent } from "react";
3
+ import { useState, type KeyboardEvent, type ReactNode } from "react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { ArrowUp } from "lucide-react";
6
6
  import { Button, Textarea } from "../../../../shadcnui";
@@ -22,6 +22,11 @@ interface Props {
22
22
  * move.
23
23
  */
24
24
  sendLabel?: string;
25
+ /**
26
+ * Rendered at the bottom-left of the input box, opposite the send button
27
+ * (e.g. a scope picker). When omitted the box keeps its single-row layout.
28
+ */
29
+ leading?: ReactNode;
25
30
  }
26
31
 
27
32
  export function AssistantComposer({
@@ -31,6 +36,7 @@ export function AssistantComposer({
31
36
  onValueChange,
32
37
  placeholder,
33
38
  sendLabel,
39
+ leading,
34
40
  }: Props) {
35
41
  const t = useTranslations();
36
42
  const [internal, setInternal] = useState("");
@@ -53,22 +59,39 @@ export function AssistantComposer({
53
59
  }
54
60
  };
55
61
 
62
+ const textarea = (
63
+ <Textarea
64
+ value={value}
65
+ onChange={(e) => setValue(e.target.value)}
66
+ onKeyDown={onKeyDown}
67
+ placeholder={placeholder ?? t("features.assistant.composer_placeholder")}
68
+ disabled={disabled}
69
+ rows={2}
70
+ className="min-h-[48px] resize-none border-0 bg-transparent focus-visible:ring-0"
71
+ />
72
+ );
73
+ const sendButton = (
74
+ <Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
75
+ <ArrowUp className="me-1 h-4 w-4" /> {sendLabel ?? t("features.assistant.send")}
76
+ </Button>
77
+ );
78
+
56
79
  return (
57
80
  <div className="flex flex-col gap-1 border-t p-4">
58
- <div className="bg-muted/30 flex items-end gap-2 rounded-lg border p-2">
59
- <Textarea
60
- value={value}
61
- onChange={(e) => setValue(e.target.value)}
62
- onKeyDown={onKeyDown}
63
- placeholder={placeholder ?? t("features.assistant.composer_placeholder")}
64
- disabled={disabled}
65
- rows={2}
66
- className="min-h-[48px] resize-none border-0 bg-transparent focus-visible:ring-0"
67
- />
68
- <Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
69
- <ArrowUp className="me-1 h-4 w-4" /> {sendLabel ?? t("features.assistant.send")}
70
- </Button>
71
- </div>
81
+ {leading ? (
82
+ <div className="bg-muted/30 flex flex-col gap-2 rounded-lg border p-2">
83
+ {textarea}
84
+ <div className="flex items-center justify-between gap-2">
85
+ <div className="min-w-0">{leading}</div>
86
+ {sendButton}
87
+ </div>
88
+ </div>
89
+ ) : (
90
+ <div className="bg-muted/30 flex items-end gap-2 rounded-lg border p-2">
91
+ {textarea}
92
+ {sendButton}
93
+ </div>
94
+ )}
72
95
  <div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
73
96
  </div>
74
97
  );
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, type ReactNode } from "react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { Sparkles } from "lucide-react";
6
6
  import { Label, Switch } from "../../../../shadcnui";
@@ -16,11 +16,13 @@ interface Props {
16
16
  */
17
17
  operatorMode?: boolean;
18
18
  onOperatorModeChange?: (value: boolean) => void;
19
+ /** Rendered inside the composer, bottom-left (e.g. a scope picker). */
20
+ composerLeading?: ReactNode;
19
21
  }
20
22
 
21
23
  const STARTER_KEYS = ["a", "b", "c", "d"] as const;
22
24
 
23
- export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange }: Props) {
25
+ export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorModeChange, composerLeading }: Props) {
24
26
  const t = useTranslations();
25
27
  const [draft, setDraft] = useState("");
26
28
 
@@ -34,7 +36,7 @@ export function AssistantEmptyState({ onSend, operatorMode = false, onOperatorMo
34
36
  <SectionHeader>{t("features.assistant.empty_state.title")}</SectionHeader>
35
37
  <p className="text-muted-foreground mt-1 text-sm">{t("features.assistant.empty_state.subtitle")}</p>
36
38
  </div>
37
- <AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} />
39
+ <AssistantComposer value={draft} onValueChange={setDraft} onSend={onSend} leading={composerLeading} />
38
40
  {onOperatorModeChange && (
39
41
  <div className="flex items-center justify-end gap-2">
40
42
  <Switch
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
4
  import { Plus } from "lucide-react";
5
+ import type { ReactNode } from "react";
5
6
  import { Button } from "../../../../shadcnui";
6
7
  import { MicroLabel } from "../../../../components/typography";
7
8
  import type { AssistantInterface } from "../../data/AssistantInterface";
@@ -11,7 +12,12 @@ interface Props {
11
12
  threads: AssistantInterface[];
12
13
  activeId?: string;
13
14
  onSelect: (id: string) => void;
14
- onNew: () => void;
15
+ /**
16
+ * Starts a new thread. Omit it when the host renders the "new" action
17
+ * elsewhere (the `/assistants` page puts it in the page header), and the
18
+ * sidebar then shows only the thread list.
19
+ */
20
+ onNew?: () => void;
15
21
  /**
16
22
  * Label of the "start a new thread" button. Defaults to the assistant's own
17
23
  * copy; a surface that is not the assistant (the handbook chat) passes its
@@ -20,9 +26,19 @@ interface Props {
20
26
  newLabel?: string;
21
27
  /** Shown when there are no threads. Same rationale as `newLabel`. */
22
28
  emptyLabel?: string;
29
+ /** Optional badge rendered inside each thread button, after the title (e.g. the thread's scope). */
30
+ renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
23
31
  }
24
32
 
25
- export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel, emptyLabel }: Props) {
33
+ export function AssistantSidebar({
34
+ threads,
35
+ activeId,
36
+ onSelect,
37
+ onNew,
38
+ newLabel,
39
+ emptyLabel,
40
+ renderThreadBadge,
41
+ }: Props) {
26
42
  const t = useTranslations();
27
43
  const groups = groupThreadsByBucket(threads);
28
44
 
@@ -42,6 +58,7 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
42
58
  }
43
59
  >
44
60
  {thread.title}
61
+ {renderThreadBadge?.(thread)}
45
62
  </button>
46
63
  ))}
47
64
  </div>
@@ -50,11 +67,13 @@ export function AssistantSidebar({ threads, activeId, onSelect, onNew, newLabel,
50
67
 
51
68
  return (
52
69
  <aside className="bg-muted/30 flex w-64 flex-col border-e">
53
- <div className="border-b p-3">
54
- <Button onClick={onNew} className="w-full" size="sm">
55
- <Plus className="me-1 h-4 w-4" /> {newLabel ?? t("features.assistant.new")}
56
- </Button>
57
- </div>
70
+ {onNew && (
71
+ <div className="border-b p-3">
72
+ <Button onClick={onNew} className="w-full" size="sm">
73
+ <Plus className="me-1 h-4 w-4" /> {newLabel ?? t("features.assistant.new")}
74
+ </Button>
75
+ </div>
76
+ )}
58
77
  <div className="flex-1 overflow-y-auto p-2">
59
78
  {threads.length === 0 ? (
60
79
  <div className="text-muted-foreground mt-6 text-center text-xs">
@@ -29,4 +29,12 @@ describe("AssistantComposer", () => {
29
29
  const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
30
30
  expect(send).toBeDisabled();
31
31
  });
32
+
33
+ it("renders the leading slot inside the input box, before the send button", () => {
34
+ render(<AssistantComposer onSend={vi.fn()} leading={<span data-testid="leading-slot" />} />);
35
+ const slot = screen.getByTestId("leading-slot");
36
+ const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
37
+ expect(slot.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
38
+ expect(slot.closest(".rounded-lg")).toBe(send.closest(".rounded-lg"));
39
+ });
32
40
  });
@@ -55,4 +55,11 @@ describe("AssistantSidebar", () => {
55
55
  await userEvent.click(screen.getByRole("button", { name: /^T$/ }));
56
56
  expect(onSelect).toHaveBeenCalledWith("a1");
57
57
  });
58
+
59
+ it("without onNew renders no new button", () => {
60
+ const threads = [buildAssistantStub({ id: "a1", title: "T", updatedAt: new Date("2026-04-22T09:00:00Z") })];
61
+ render(<AssistantSidebar threads={threads} onSelect={vi.fn()} />);
62
+ expect(screen.queryByRole("button", { name: /features\.assistant\.new/ })).not.toBeInTheDocument();
63
+ expect(screen.getByRole("button", { name: /^T$/ })).toBeInTheDocument();
64
+ });
58
65
  });
@@ -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}>