@carlonicora/nextjs-jsonapi 2.0.3 → 2.1.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 (98) hide show
  1. package/dist/AssistantInterface-DMh-zApr.d.mts +34 -0
  2. package/dist/AssistantInterface-DwdBzS9f.d.ts +34 -0
  3. package/dist/{BlockNoteEditor-QMZLV62B.js → BlockNoteEditor-C5CYCS3I.js} +22 -20
  4. package/dist/BlockNoteEditor-C5CYCS3I.js.map +1 -0
  5. package/dist/{BlockNoteEditor-QGEOVS3T.mjs → BlockNoteEditor-NXDUGMP7.mjs} +7 -5
  6. package/dist/BlockNoteEditor-NXDUGMP7.mjs.map +1 -0
  7. package/dist/{BlockNoteViewer-UW5VZ5UF.js → BlockNoteViewer-6N2GQWTI.js} +4 -2
  8. package/dist/BlockNoteViewer-6N2GQWTI.js.map +1 -0
  9. package/dist/{BlockNoteViewer-OPSCTHKJ.mjs → BlockNoteViewer-IBQRJ3WM.mjs} +4 -2
  10. package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +1 -0
  11. package/dist/billing/index.js +357 -357
  12. package/dist/billing/index.mjs +3 -3
  13. package/dist/{chunk-DS4IBFWA.js → chunk-6YIZX26T.js} +185 -33
  14. package/dist/chunk-6YIZX26T.js.map +1 -0
  15. package/dist/{chunk-QHGSBZCL.mjs → chunk-A7AGXKZX.mjs} +1094 -732
  16. package/dist/chunk-A7AGXKZX.mjs.map +1 -0
  17. package/dist/{chunk-PBKZYJRJ.js → chunk-I3GHIU4Y.js} +1254 -892
  18. package/dist/chunk-I3GHIU4Y.js.map +1 -0
  19. package/dist/{chunk-UXJAS6C5.js → chunk-JG7MCYCW.js} +7 -7
  20. package/dist/{chunk-UXJAS6C5.js.map → chunk-JG7MCYCW.js.map} +1 -1
  21. package/dist/{chunk-HN5ED7OF.mjs → chunk-MAWTFEY3.mjs} +2 -2
  22. package/dist/{chunk-BIL2WQEV.mjs → chunk-P5TMD7GK.mjs} +158 -6
  23. package/dist/chunk-P5TMD7GK.mjs.map +1 -0
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.d.mts +102 -11
  27. package/dist/components/index.d.ts +102 -11
  28. package/dist/components/index.js +12 -4
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/index.mjs +11 -3
  31. package/dist/contexts/index.d.mts +29 -3
  32. package/dist/contexts/index.d.ts +29 -3
  33. package/dist/contexts/index.js +4 -4
  34. package/dist/contexts/index.mjs +3 -3
  35. package/dist/core/index.d.mts +99 -2
  36. package/dist/core/index.d.ts +99 -2
  37. package/dist/core/index.js +8 -2
  38. package/dist/core/index.js.map +1 -1
  39. package/dist/core/index.mjs +7 -1
  40. package/dist/features/help/index.js +37 -37
  41. package/dist/features/help/index.mjs +3 -3
  42. package/dist/features/tokenusage/index.js +71 -71
  43. package/dist/features/tokenusage/index.mjs +3 -3
  44. package/dist/index.d.mts +2 -2
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +9 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +8 -2
  49. package/dist/server/index.js +3 -3
  50. package/dist/server/index.mjs +1 -1
  51. package/package.json +1 -1
  52. package/src/components/editors/BlockNoteEditor.tsx +5 -1
  53. package/src/components/editors/BlockNoteEditorSuggestionMenuController.tsx +14 -2
  54. package/src/components/index.ts +1 -0
  55. package/src/components/viewers/BlockNoteViewer.tsx +5 -1
  56. package/src/core/index.ts +2 -0
  57. package/src/core/registry/ModuleRegistry.ts +1 -0
  58. package/src/features/assistant/components/containers/AssistantPageContainer.tsx +123 -0
  59. package/src/features/assistant/components/index.ts +2 -0
  60. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +135 -0
  61. package/src/features/assistant/components/parts/AssistantThread.tsx +4 -1
  62. package/src/features/assistant/contexts/AssistantContext.tsx +60 -13
  63. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +81 -0
  64. package/src/features/assistant/data/Assistant.ts +29 -1
  65. package/src/features/assistant/data/AssistantInterface.ts +15 -0
  66. package/src/features/assistant/data/AssistantService.ts +19 -2
  67. package/src/features/assistant/data/__tests__/Assistant.scope.spec.ts +64 -0
  68. package/src/features/assistant-action/AssistantActionModule.ts +13 -0
  69. package/src/features/assistant-action/components/ApprovalActionCard.tsx +127 -0
  70. package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +34 -0
  71. package/src/features/assistant-action/components/index.ts +2 -0
  72. package/src/features/assistant-action/data/AssistantAction.ts +58 -0
  73. package/src/features/assistant-action/data/AssistantActionInterface.ts +24 -0
  74. package/src/features/assistant-action/data/AssistantActionService.ts +51 -0
  75. package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +171 -0
  76. package/src/features/assistant-action/data/index.ts +3 -0
  77. package/src/features/assistant-action/index.ts +2 -0
  78. package/src/features/assistant-message/components/MessageItem.tsx +57 -6
  79. package/src/features/assistant-message/components/MessageList.tsx +11 -2
  80. package/src/features/assistant-message/components/__tests__/MessageItem.mentions.spec.tsx +34 -0
  81. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +30 -13
  82. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  83. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +3 -1
  84. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +4 -1
  85. package/src/features/assistant-message/components/parts/tabs/useEntityLabel.ts +35 -0
  86. package/src/features/assistant-message/data/AssistantMessage.ts +11 -2
  87. package/src/features/index.ts +1 -0
  88. package/dist/AssistantInterface-B56qznuH.d.mts +0 -16
  89. package/dist/AssistantInterface-CHiJIYDY.d.ts +0 -16
  90. package/dist/BlockNoteEditor-QGEOVS3T.mjs.map +0 -1
  91. package/dist/BlockNoteEditor-QMZLV62B.js.map +0 -1
  92. package/dist/BlockNoteViewer-OPSCTHKJ.mjs.map +0 -1
  93. package/dist/BlockNoteViewer-UW5VZ5UF.js.map +0 -1
  94. package/dist/chunk-BIL2WQEV.mjs.map +0 -1
  95. package/dist/chunk-DS4IBFWA.js.map +0 -1
  96. package/dist/chunk-PBKZYJRJ.js.map +0 -1
  97. package/dist/chunk-QHGSBZCL.mjs.map +0 -1
  98. /package/dist/{chunk-HN5ED7OF.mjs.map → chunk-MAWTFEY3.mjs.map} +0 -0
@@ -523,4 +523,85 @@ describe("AssistantContext", () => {
523
523
  expect(result.current.messages.map((m) => m.content)).toEqual(["retry-me", "ok"]);
524
524
  expect(appendMock).toHaveBeenCalledTimes(2);
525
525
  });
526
+
527
+ it("loads only threads bound to the scope", async () => {
528
+ const findMany = vi.fn().mockResolvedValue([]);
529
+ AssistantService.findMany = findMany;
530
+
531
+ renderHook(() => useAssistantContext(), {
532
+ wrapper: ({ children }) => (
533
+ <AssistantProvider scope={{ type: "campaigns", id: "camp-1" }}>{children}</AssistantProvider>
534
+ ),
535
+ });
536
+
537
+ await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" }));
538
+ });
539
+
540
+ it("creates the first thread bound to the scope", async () => {
541
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
542
+ const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a1" }));
543
+ AssistantService.create = create;
544
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
545
+
546
+ const { result } = renderHook(() => useAssistantContext(), {
547
+ wrapper: ({ children }) => (
548
+ <AssistantProvider scope={{ type: "campaigns", id: "camp-1" }}>{children}</AssistantProvider>
549
+ ),
550
+ });
551
+ await act(async () => {
552
+ await result.current.sendMessage("hello");
553
+ });
554
+
555
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "campaigns", id: "camp-1" } }));
556
+ replaceState.mockRestore();
557
+ });
558
+
559
+ it("uses threadUrl for history replacement", () => {
560
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
561
+
562
+ const { result } = renderHook(() => useAssistantContext(), {
563
+ wrapper: ({ children }) => (
564
+ <AssistantProvider
565
+ scope={{ type: "campaigns", id: "camp-1" }}
566
+ threadUrl={(id?: string) => (id ? `/campaigns/camp-1/assistant/${id}` : "/campaigns/camp-1/assistant")}
567
+ >
568
+ {children}
569
+ </AssistantProvider>
570
+ ),
571
+ });
572
+
573
+ act(() => {
574
+ result.current.startNew();
575
+ });
576
+
577
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/campaigns/camp-1/assistant");
578
+ replaceState.mockRestore();
579
+ });
580
+
581
+ it("forwards contentBlocks from the composer to create and appendMessage", async () => {
582
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
583
+ const blocks = [{ type: "paragraph", content: [] }];
584
+
585
+ AssistantService.create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-blocks" }));
586
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
587
+ const { result } = renderHook(() => useAssistantContext(), {
588
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
589
+ });
590
+ await act(async () => {
591
+ await result.current.sendMessage("hello", { contentBlocks: blocks });
592
+ });
593
+ expect(AssistantService.create).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks }));
594
+
595
+ const existing = buildAssistantDehydrated({ id: "a-blocks-2" });
596
+ AssistantService.appendMessage = vi.fn().mockResolvedValue([]);
597
+ const { result: followUp } = renderHook(() => useAssistantContext(), {
598
+ wrapper: ({ children }) => <AssistantProvider dehydratedAssistant={existing}>{children}</AssistantProvider>,
599
+ });
600
+ await act(async () => {
601
+ await followUp.current.sendMessage("more", { contentBlocks: blocks });
602
+ });
603
+ expect(AssistantService.appendMessage).toHaveBeenCalledWith(expect.objectContaining({ contentBlocks: blocks }));
604
+
605
+ replaceState.mockRestore();
606
+ });
526
607
  });
@@ -5,6 +5,8 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
5
5
  private _title?: string;
6
6
  private _messageCount?: number;
7
7
  private _engine?: string;
8
+ private _boundContentType?: string;
9
+ private _boundContentId?: string;
8
10
 
9
11
  get title(): string {
10
12
  return this._title ?? "";
@@ -18,6 +20,14 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
18
20
  return this._engine;
19
21
  }
20
22
 
23
+ get boundContentType(): string | undefined {
24
+ return this._boundContentType;
25
+ }
26
+
27
+ get boundContentId(): string | undefined {
28
+ return this._boundContentId;
29
+ }
30
+
21
31
  rehydrate(data: JsonApiHydratedDataInterface): this {
22
32
  super.rehydrate(data);
23
33
  this._title = data.jsonApi.attributes?.title;
@@ -25,6 +35,13 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
25
35
  const fromMeta = data.jsonApi.meta?.messageCount;
26
36
  const fromAttrs = data.jsonApi.attributes?.messageCount;
27
37
  this._messageCount = typeof fromMeta === "number" ? fromMeta : typeof fromAttrs === "number" ? fromAttrs : 0;
38
+ // Read the linkage pointer directly instead of `_readIncluded`: the bound
39
+ // resource is not side-loaded on this endpoint and only its {type, id} is
40
+ // needed to know which scope the thread belongs to.
41
+ const contentLinkage = data.jsonApi.relationships?.content?.data;
42
+ const boundContent = Array.isArray(contentLinkage) ? contentLinkage[0] : contentLinkage;
43
+ this._boundContentType = boundContent?.type;
44
+ this._boundContentId = boundContent?.id;
28
45
  return this;
29
46
  }
30
47
 
@@ -33,11 +50,22 @@ export class Assistant extends AbstractApiData implements AssistantInterface {
33
50
  data: {
34
51
  type: Modules.Assistant.name,
35
52
  attributes: {
36
- content: data.firstMessage,
53
+ // narr8 convention for BlockNote-backed fields: the document travels
54
+ // as a JSON string in the entity's own string attribute, exactly as
55
+ // Npc.description does (`JSON.stringify` out, `JSON.parse` back).
56
+ // The server detects the shape and derives the stored markdown.
57
+ content: data.contentBlocks !== undefined ? JSON.stringify(data.contentBlocks) : data.firstMessage,
37
58
  ...(data.title !== undefined ? { title: data.title } : {}),
38
59
  ...(data.howToMode !== undefined ? { howToMode: data.howToMode } : {}),
39
60
  ...(data.limitToHowToId !== undefined ? { limitToHowToId: data.limitToHowToId } : {}),
40
61
  },
62
+ ...(data.boundContent !== undefined
63
+ ? {
64
+ relationships: {
65
+ content: { data: { type: data.boundContent.type, id: data.boundContent.id } },
66
+ },
67
+ }
68
+ : {}),
41
69
  },
42
70
  included: [],
43
71
  };
@@ -5,6 +5,17 @@ export type AssistantInput = {
5
5
  title?: string;
6
6
  howToMode?: boolean;
7
7
  limitToHowToId?: string;
8
+ /**
9
+ * BlockNote document behind `firstMessage`. Sent alongside the flattened
10
+ * text so the server can re-derive the content and read `@`-mentions from
11
+ * the inline nodes; the flattened text is never trusted on its own.
12
+ */
13
+ contentBlocks?: unknown[];
14
+ /**
15
+ * Resource the thread is bound to (e.g. a campaign). Confines every agent
16
+ * data-access point in the run to that resource's scope.
17
+ */
18
+ boundContent?: { type: string; id: string };
8
19
  };
9
20
 
10
21
  export interface AssistantInterface extends ApiDataInterface {
@@ -12,4 +23,8 @@ export interface AssistantInterface extends ApiDataInterface {
12
23
  get messageCount(): number;
13
24
  /** Agent engine the thread runs on — `"operator"` or undefined (responder). */
14
25
  get engine(): string | undefined;
26
+ /** JSON:API type of the resource this thread is bound to, when bound. */
27
+ get boundContentType(): string | undefined;
28
+ /** Id of the resource this thread is bound to, when bound. */
29
+ get boundContentId(): string | undefined;
15
30
  }
@@ -12,9 +12,18 @@ export class AssistantService extends AbstractService {
12
12
  });
13
13
  }
14
14
 
15
- static async findMany(params: { fetchAll?: boolean } = {}): Promise<AssistantInterface[]> {
15
+ /**
16
+ * Lists threads. `boundType`/`boundId` narrow the list to the threads bound
17
+ * to one resource (e.g. a campaign), so a scoped surface never shows the
18
+ * user's unrelated threads.
19
+ */
20
+ static async findMany(
21
+ params: { fetchAll?: boolean; boundType?: string; boundId?: string } = {},
22
+ ): Promise<AssistantInterface[]> {
16
23
  const endpoint = new EndpointCreator({ endpoint: Modules.Assistant });
17
24
  if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
25
+ if (params.boundType) endpoint.addAdditionalParam("boundType", params.boundType);
26
+ if (params.boundId) endpoint.addAdditionalParam("boundId", params.boundId);
18
27
  return this.callApi({
19
28
  type: Modules.Assistant,
20
29
  method: HttpMethod.GET,
@@ -58,6 +67,8 @@ export class AssistantService extends AbstractService {
58
67
  content: string;
59
68
  howToMode?: boolean;
60
69
  limitToHowToId?: string;
70
+ /** BlockNote document. Serialised by the model into `content`; never its own attribute. */
71
+ contentBlocks?: unknown[];
61
72
  }): Promise<AssistantMessageInterface[]> {
62
73
  const message = new AssistantMessage();
63
74
  return this.callApi<AssistantMessageInterface[]>({
@@ -72,6 +83,7 @@ export class AssistantService extends AbstractService {
72
83
  content: params.content,
73
84
  howToMode: params.howToMode,
74
85
  limitToHowToId: params.limitToHowToId,
86
+ contentBlocks: params.contentBlocks,
75
87
  }),
76
88
  overridesJsonApiCreation: true,
77
89
  });
@@ -86,6 +98,8 @@ export class AssistantService extends AbstractService {
86
98
  static async appendMessageOperator(params: {
87
99
  assistantId: string;
88
100
  content: string;
101
+ /** BlockNote document. Serialised by the model into `content`; never its own attribute. */
102
+ contentBlocks?: unknown[];
89
103
  }): Promise<AssistantMessageInterface[]> {
90
104
  const message = new AssistantMessage();
91
105
  return this.callApi<AssistantMessageInterface[]>({
@@ -96,7 +110,10 @@ export class AssistantService extends AbstractService {
96
110
  id: params.assistantId,
97
111
  childEndpoint: Modules.AssistantMessage,
98
112
  }).generate(),
99
- input: message.createAppendMessageJsonApi({ content: params.content }),
113
+ input: message.createAppendMessageJsonApi({
114
+ content: params.content,
115
+ contentBlocks: params.contentBlocks,
116
+ }),
100
117
  overridesJsonApiCreation: true,
101
118
  });
102
119
  }
@@ -0,0 +1,64 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { Assistant } from "../Assistant";
5
+
6
+ // `createJsonApi()` reads `Modules.Assistant.name` for the JSON:API `type`, so
7
+ // the module must exist in the registry. ModuleRegistry is backed by a
8
+ // globalThis symbol shared across test files in a worker, hence the
9
+ // register-if-absent guard (same idiom as AssistantAction.spec.ts).
10
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
11
+ try {
12
+ ModuleRegistry.get(key as any);
13
+ } catch {
14
+ ModuleRegistry.register(key, module);
15
+ }
16
+ };
17
+
18
+ beforeAll(() => {
19
+ registerIfAbsent("Assistant", { name: "assistants", model: Assistant } as any);
20
+ });
21
+
22
+ describe("Assistant.createJsonApi", () => {
23
+ it("emits the bound content relationship", () => {
24
+ const payload: any = new Assistant().createJsonApi({
25
+ firstMessage: "hello",
26
+ boundContent: { type: "campaigns", id: "camp-1" },
27
+ });
28
+
29
+ expect(payload.data.relationships.content).toEqual({ data: { type: "campaigns", id: "camp-1" } });
30
+ expect(payload.data.attributes.content).toBe("hello");
31
+ });
32
+
33
+ it("serialises the rich document into `content` when the composer supplied one", () => {
34
+ // BlockNote-backed fields travel as a JSON string inside the entity's own
35
+ // string attribute (same convention as Npc.description) — there is no
36
+ // separate `contentBlocks` attribute on the wire.
37
+ const blocks = [{ type: "paragraph", content: [] }];
38
+ const payload: any = new Assistant().createJsonApi({ firstMessage: "hello", contentBlocks: blocks });
39
+ expect(payload.data.attributes.content).toBe(JSON.stringify(blocks));
40
+ expect(payload.data.attributes.contentBlocks).toBeUndefined();
41
+ });
42
+
43
+ it("omits the relationships key entirely when no content is bound", () => {
44
+ const payload: any = new Assistant().createJsonApi({ firstMessage: "hello" });
45
+ expect(payload.data.relationships?.content).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("Assistant.rehydrate", () => {
50
+ it("exposes the bound content type and id", () => {
51
+ const assistant = new Assistant().rehydrate({
52
+ jsonApi: {
53
+ type: "assistants",
54
+ id: "a1",
55
+ attributes: { title: "T" },
56
+ relationships: { content: { data: { type: "campaigns", id: "camp-1" } } },
57
+ },
58
+ included: [],
59
+ } as any);
60
+
61
+ expect(assistant.boundContentType).toBe("campaigns");
62
+ expect(assistant.boundContentId).toBe("camp-1");
63
+ });
64
+ });
@@ -0,0 +1,13 @@
1
+ import { ShieldCheckIcon } from "lucide-react";
2
+ import { ModuleFactory } from "../../permissions";
3
+ import { AssistantAction } from "./data/AssistantAction";
4
+
5
+ export const AssistantActionModule = (factory: ModuleFactory) =>
6
+ factory({
7
+ pageUrl: "/assistant-actions",
8
+ name: "assistant-actions",
9
+ model: AssistantAction,
10
+ identifier: ["summary"],
11
+ moduleId: "e3c27517-bac0-43cb-adff-cc20c4210c12",
12
+ icon: ShieldCheckIcon,
13
+ });
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { ShieldAlert } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useState } from "react";
6
+ import { MicroLabel } from "../../../components/typography";
7
+ import { Badge, Button } from "../../../shadcnui";
8
+ import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
9
+ import type { AssistantActionStatus } from "../data/AssistantActionInterface";
10
+ import { AssistantActionService } from "../data/AssistantActionService";
11
+
12
+ /**
13
+ * Status pill variants. Soft `Badge` variants only — a hand-rolled
14
+ * `bg-*-100 text-*-800` span is never a status pill (typography rule 8).
15
+ */
16
+ const STATUS_VARIANT: Record<AssistantActionStatus, "softBlue" | "softGreen" | "softRed" | "softGray"> = {
17
+ pending: "softBlue",
18
+ approved: "softBlue",
19
+ executed: "softGreen",
20
+ denied: "softRed",
21
+ failed: "softRed",
22
+ expired: "softGray",
23
+ };
24
+
25
+ /**
26
+ * Literal i18n keys (not a template literal) so the key set stays greppable
27
+ * and every one of them can be verified against the app's messages file.
28
+ */
29
+ const STATUS_LABEL_KEY: Record<AssistantActionStatus, string> = {
30
+ pending: "features.assistant.approval.status.pending",
31
+ approved: "features.assistant.approval.status.approved",
32
+ executed: "features.assistant.approval.status.executed",
33
+ denied: "features.assistant.approval.status.denied",
34
+ failed: "features.assistant.approval.status.failed",
35
+ expired: "features.assistant.approval.status.expired",
36
+ };
37
+
38
+ interface Props {
39
+ /** Id of the pending AssistantAction linked to the approval-request message. */
40
+ actionId: string;
41
+ /** Human-readable summary fallback (the approval-request message content). */
42
+ summary?: string;
43
+ /** Invoked with the resumed assistant message returned by approve/deny. */
44
+ onResolved?: (message: AssistantMessageInterface) => void;
45
+ }
46
+
47
+ /**
48
+ * Renders an `approval-request` assistant message as an action card:
49
+ * the destructive-tool summary plus Approve / Deny buttons. Buttons are
50
+ * only actionable while the underlying AssistantAction is `pending`.
51
+ *
52
+ * NOTE: plain `<Button>` elements on purpose — never wrapped inside any
53
+ * Base UI trigger component (no nested buttons, no `asChild`).
54
+ */
55
+ export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
56
+ const t = useTranslations();
57
+ const [status, setStatus] = useState<AssistantActionStatus | undefined>(undefined);
58
+ const [actionSummary, setActionSummary] = useState<string | undefined>(undefined);
59
+ const [busy, setBusy] = useState<boolean>(false);
60
+ const [failed, setFailed] = useState<boolean>(false);
61
+
62
+ useEffect(() => {
63
+ let cancelled = false;
64
+ (async () => {
65
+ try {
66
+ const action = await AssistantActionService.findOne({ id: actionId });
67
+ if (cancelled || !action) return;
68
+ setStatus(action.status);
69
+ setActionSummary(action.summary);
70
+ } catch (error) {
71
+ console.error(`ApprovalActionCard: failed to fetch assistant action ${actionId}`, error);
72
+ if (!cancelled) setFailed(true);
73
+ }
74
+ })();
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [actionId]);
79
+
80
+ const actionable = status === "pending" && !busy && !failed;
81
+
82
+ const resolve = async (kind: "approve" | "deny") => {
83
+ if (!actionable) return;
84
+ setBusy(true);
85
+ try {
86
+ const message =
87
+ kind === "approve"
88
+ ? await AssistantActionService.approve({ id: actionId })
89
+ : await AssistantActionService.deny({ id: actionId });
90
+ setStatus(kind === "approve" ? "executed" : "denied");
91
+ if (message) onResolved?.(message);
92
+ } catch (error) {
93
+ console.error(`ApprovalActionCard: failed to ${kind} assistant action ${actionId}`, error);
94
+ setFailed(true);
95
+ } finally {
96
+ setBusy(false);
97
+ }
98
+ };
99
+
100
+ return (
101
+ <div className="border-border bg-muted/30 flex flex-col gap-2 rounded-xl border p-3.5">
102
+ <MicroLabel className="flex items-center gap-2">
103
+ <ShieldAlert className="text-warning h-3.5 w-3.5" />
104
+ <span>{t("features.assistant.approval.title")}</span>
105
+ {status && status !== "pending" && (
106
+ <Badge variant={STATUS_VARIANT[status]} className="ml-auto tracking-normal normal-case">
107
+ {t(STATUS_LABEL_KEY[status])}
108
+ </Badge>
109
+ )}
110
+ </MicroLabel>
111
+ <div className="text-foreground text-sm leading-relaxed">{actionSummary ?? summary}</div>
112
+ {failed && (
113
+ <div role="alert" className="text-destructive text-xs/relaxed">
114
+ {t("features.assistant.approval.error")}
115
+ </div>
116
+ )}
117
+ <div className="flex items-center gap-2 pt-1">
118
+ <Button size="sm" disabled={!actionable} onClick={() => void resolve("approve")}>
119
+ {t("features.assistant.approval.approve")}
120
+ </Button>
121
+ <Button size="sm" variant="outline" disabled={!actionable} onClick={() => void resolve("deny")}>
122
+ {t("features.assistant.approval.deny")}
123
+ </Button>
124
+ </div>
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
4
+ import { AssistantContainer } from "../../assistant/components/containers/AssistantContainer";
5
+ import { useAssistantContext } from "../../assistant/contexts/AssistantContext";
6
+ import { ApprovalActionCard } from "./ApprovalActionCard";
7
+
8
+ /**
9
+ * AssistantContainer with the operator approval card wired in. The container
10
+ * renders `approval-request` messages through the `renderApprovalAction` slot,
11
+ * which stays a slot so the plain chat container has no dependency on the
12
+ * AssistantAction module. Must be rendered inside an `AssistantProvider`.
13
+ */
14
+ export function AssistantContainerWithApprovals() {
15
+ const ctx = useAssistantContext();
16
+
17
+ return (
18
+ <AssistantContainer
19
+ renderApprovalAction={(message: AssistantMessageInterface) =>
20
+ message.actionId ? (
21
+ <ApprovalActionCard
22
+ actionId={message.actionId}
23
+ summary={message.content}
24
+ onResolved={ctx.appendResolvedMessage}
25
+ />
26
+ ) : (
27
+ <div className="bg-muted text-foreground rounded-2xl rounded-bl-sm px-3.5 py-2.5 text-sm leading-relaxed">
28
+ {message.content}
29
+ </div>
30
+ )
31
+ }
32
+ />
33
+ );
34
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./ApprovalActionCard";
2
+ export * from "./AssistantContainerWithApprovals";
@@ -0,0 +1,58 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { AssistantActionInput, AssistantActionInterface, AssistantActionStatus } from "./AssistantActionInterface";
3
+
4
+ export class AssistantAction extends AbstractApiData implements AssistantActionInterface {
5
+ private _status?: AssistantActionStatus;
6
+ private _toolName?: string;
7
+ private _summary?: string;
8
+ private _resolvedAt?: Date;
9
+ private _expiresAt?: Date;
10
+
11
+ get status(): AssistantActionStatus {
12
+ // Fail-safe: a missing status must never render actionable Approve/Deny buttons, so default to the non-actionable "expired".
13
+ return this._status ?? "expired";
14
+ }
15
+
16
+ get toolName(): string {
17
+ return this._toolName ?? "";
18
+ }
19
+
20
+ get summary(): string {
21
+ return this._summary ?? "";
22
+ }
23
+
24
+ get resolvedAt(): Date | undefined {
25
+ return this._resolvedAt;
26
+ }
27
+
28
+ get expiresAt(): Date | undefined {
29
+ return this._expiresAt;
30
+ }
31
+
32
+ rehydrate(data: JsonApiHydratedDataInterface): this {
33
+ super.rehydrate(data);
34
+
35
+ this._status = data.jsonApi.attributes.status;
36
+ this._toolName = data.jsonApi.attributes.toolName;
37
+ this._summary = data.jsonApi.attributes.summary;
38
+ this._resolvedAt = data.jsonApi.attributes.resolvedAt ? new Date(data.jsonApi.attributes.resolvedAt) : undefined;
39
+ this._expiresAt = data.jsonApi.attributes.expiresAt ? new Date(data.jsonApi.attributes.expiresAt) : undefined;
40
+
41
+ return this;
42
+ }
43
+
44
+ /**
45
+ * Minimal payload (id only): the frontend never POSTs assistant actions —
46
+ * approve/deny are body-less POSTs handled by AssistantActionService.
47
+ */
48
+ createJsonApi(data: AssistantActionInput) {
49
+ return {
50
+ data: {
51
+ type: Modules.AssistantAction.name,
52
+ id: data.id,
53
+ attributes: {},
54
+ },
55
+ included: [],
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,24 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ /**
4
+ * Lifecycle of a destructive tool call the operator engine paused on.
5
+ * Only `pending` is actionable from the UI.
6
+ */
7
+ export type AssistantActionStatus = "pending" | "approved" | "denied" | "expired" | "executed" | "failed";
8
+
9
+ /**
10
+ * The frontend never creates assistant actions — the operator engine does.
11
+ * The input therefore carries the id alone, so `createJsonApi()` can emit a
12
+ * well-formed (if minimal) JSON:API resource object.
13
+ */
14
+ export type AssistantActionInput = {
15
+ id: string;
16
+ };
17
+
18
+ export interface AssistantActionInterface extends ApiDataInterface {
19
+ get status(): AssistantActionStatus;
20
+ get toolName(): string;
21
+ get summary(): string;
22
+ get resolvedAt(): Date | undefined;
23
+ get expiresAt(): Date | undefined;
24
+ }
@@ -0,0 +1,51 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
3
+ import { AssistantActionInterface } from "./AssistantActionInterface";
4
+
5
+ export class AssistantActionService extends AbstractService {
6
+ /**
7
+ * GET single assistant action by ID
8
+ */
9
+ static async findOne(params: { id: string }): Promise<AssistantActionInterface> {
10
+ return this.callApi<AssistantActionInterface>({
11
+ type: Modules.AssistantAction,
12
+ method: HttpMethod.GET,
13
+ endpoint: new EndpointCreator({
14
+ endpoint: Modules.AssistantAction,
15
+ id: params.id,
16
+ }).generate(),
17
+ });
18
+ }
19
+
20
+ /**
21
+ * POST (body-less) approve action — resumes the paused run and
22
+ * returns the resumed assistant message.
23
+ */
24
+ static async approve(params: { id: string }): Promise<AssistantMessageInterface> {
25
+ return this.callApi<AssistantMessageInterface>({
26
+ type: Modules.AssistantMessage,
27
+ method: HttpMethod.POST,
28
+ endpoint: new EndpointCreator({
29
+ endpoint: Modules.AssistantAction,
30
+ id: params.id,
31
+ childEndpoint: "approve",
32
+ }).generate(),
33
+ });
34
+ }
35
+
36
+ /**
37
+ * POST (body-less) deny action — resumes the paused run with a denial and
38
+ * returns the resumed assistant message.
39
+ */
40
+ static async deny(params: { id: string }): Promise<AssistantMessageInterface> {
41
+ return this.callApi<AssistantMessageInterface>({
42
+ type: Modules.AssistantMessage,
43
+ method: HttpMethod.POST,
44
+ endpoint: new EndpointCreator({
45
+ endpoint: Modules.AssistantAction,
46
+ id: params.id,
47
+ childEndpoint: "deny",
48
+ }).generate(),
49
+ });
50
+ }
51
+ }