@carlonicora/nextjs-jsonapi 3.8.2 → 3.10.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 (104) hide show
  1. package/dist/{BlockNoteEditor-KRGDA5RX.js → BlockNoteEditor-4UAIVF3G.js} +19 -19
  2. package/dist/{BlockNoteEditor-KRGDA5RX.js.map → BlockNoteEditor-4UAIVF3G.js.map} +1 -1
  3. package/dist/BlockNoteEditor-K55A4FS2.css +30 -0
  4. package/dist/BlockNoteEditor-K55A4FS2.css.map +1 -0
  5. package/dist/{BlockNoteEditor-34AZITDR.mjs → BlockNoteEditor-PFCHUFTD.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-6N2GQWTI.js → BlockNoteViewer-C2UUOBAF.js} +17 -3
  7. package/dist/BlockNoteViewer-C2UUOBAF.js.map +1 -0
  8. package/dist/{BlockNoteViewer-IBQRJ3WM.mjs → BlockNoteViewer-TYLVTIUD.mjs} +17 -3
  9. package/dist/BlockNoteViewer-TYLVTIUD.mjs.map +1 -0
  10. package/dist/billing/index.css +22 -21
  11. package/dist/billing/index.css.map +1 -1
  12. package/dist/billing/index.js +398 -398
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/{chunk-DGAVM2LZ.js → chunk-46CQ3RRQ.js} +1315 -1069
  15. package/dist/chunk-46CQ3RRQ.js.map +1 -0
  16. package/dist/{chunk-UYLS3LJW.js → chunk-6J2SQZ3G.js} +11 -1
  17. package/dist/chunk-6J2SQZ3G.js.map +1 -0
  18. package/dist/{chunk-2D4MFVDV.mjs → chunk-IMSIVIIC.mjs} +2 -2
  19. package/dist/{chunk-HDZ2BL6D.mjs → chunk-M5QAHSCY.mjs} +11 -1
  20. package/dist/{chunk-HDZ2BL6D.mjs.map → chunk-M5QAHSCY.mjs.map} +1 -1
  21. package/dist/{chunk-TULJCLZA.js → chunk-V6CGCARD.js} +7 -7
  22. package/dist/{chunk-TULJCLZA.js.map → chunk-V6CGCARD.js.map} +1 -1
  23. package/dist/{chunk-GXSXD4OR.mjs → chunk-XZJJ4SZW.mjs} +2073 -1827
  24. package/dist/chunk-XZJJ4SZW.mjs.map +1 -0
  25. package/dist/client/index.css +22 -21
  26. package/dist/client/index.css.map +1 -1
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.css +22 -21
  30. package/dist/components/index.css.map +1 -1
  31. package/dist/components/index.d.mts +77 -15
  32. package/dist/components/index.d.ts +77 -15
  33. package/dist/components/index.js +8 -4
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/components/index.mjs +7 -3
  36. package/dist/contexts/index.css +22 -21
  37. package/dist/contexts/index.css.map +1 -1
  38. package/dist/contexts/index.d.mts +17 -4
  39. package/dist/contexts/index.d.ts +17 -4
  40. package/dist/contexts/index.js +10 -4
  41. package/dist/contexts/index.js.map +1 -1
  42. package/dist/contexts/index.mjs +9 -3
  43. package/dist/core/index.d.mts +13 -0
  44. package/dist/core/index.d.ts +13 -0
  45. package/dist/core/index.js +2 -2
  46. package/dist/core/index.mjs +1 -1
  47. package/dist/features/help/index.css +22 -21
  48. package/dist/features/help/index.css.map +1 -1
  49. package/dist/features/help/index.d.mts +6 -1
  50. package/dist/features/help/index.d.ts +6 -1
  51. package/dist/features/help/index.js +39 -38
  52. package/dist/features/help/index.js.map +1 -1
  53. package/dist/features/help/index.mjs +4 -3
  54. package/dist/features/tokenusage/index.css +22 -21
  55. package/dist/features/tokenusage/index.css.map +1 -1
  56. package/dist/features/tokenusage/index.js +102 -102
  57. package/dist/features/tokenusage/index.js.map +1 -1
  58. package/dist/features/tokenusage/index.mjs +4 -4
  59. package/dist/features/tokenusage/index.mjs.map +1 -1
  60. package/dist/index.js +3 -3
  61. package/dist/index.mjs +2 -2
  62. package/dist/{onboarding.interface-Djyl9qYu.d.mts → onboarding.interface-DVUOUC4y.d.mts} +4 -1
  63. package/dist/{onboarding.interface-Djyl9qYu.d.ts → onboarding.interface-DVUOUC4y.d.ts} +4 -1
  64. package/dist/server/index.js +3 -3
  65. package/dist/server/index.mjs +1 -1
  66. package/package.json +2 -2
  67. package/src/components/containers/RoundPageContainer.tsx +38 -16
  68. package/src/components/containers/RoundPageContainerTitle.tsx +11 -3
  69. package/src/components/containers/TabsContainer.tsx +11 -0
  70. package/src/components/forms/CommonDeleter.tsx +14 -1
  71. package/src/components/forms/EditorSheet.tsx +66 -47
  72. package/src/components/grids/ContentListGrid.tsx +1 -1
  73. package/src/components/navigations/MobileNavigationBar.tsx +1 -0
  74. package/src/components/tables/ContentListTable.tsx +1 -1
  75. package/src/components/tables/ContentTableSearch.tsx +1 -0
  76. package/src/components/viewers/BlockNoteViewer.tsx +13 -2
  77. package/src/components/viewers/BlockNoteViewerContainer.tsx +4 -1
  78. package/src/contexts/ActionBarContext.tsx +23 -0
  79. package/src/contexts/index.ts +1 -0
  80. package/src/features/assistant/components/index.ts +2 -0
  81. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  82. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +2 -2
  83. package/src/features/assistant-action/components/ApprovalActionCard.tsx +325 -4
  84. package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +268 -0
  85. package/src/features/assistant-action/data/AssistantAction.ts +12 -0
  86. package/src/features/assistant-action/data/AssistantActionInterface.ts +9 -0
  87. package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +11 -0
  88. package/src/features/help/index.ts +1 -0
  89. package/src/features/onboarding/components/OnboardingCard.tsx +1 -2
  90. package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +191 -0
  91. package/src/features/onboarding/contexts/OnboardingContext.tsx +176 -125
  92. package/src/features/onboarding/interfaces/onboarding.interface.ts +4 -0
  93. package/src/features/onboarding/styles/onboarding.css +25 -30
  94. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +1 -1
  95. package/src/shadcnui/ui/command.tsx +9 -0
  96. package/dist/BlockNoteEditor-YOAJRPWU.css +0 -29
  97. package/dist/BlockNoteEditor-YOAJRPWU.css.map +0 -1
  98. package/dist/BlockNoteViewer-6N2GQWTI.js.map +0 -1
  99. package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +0 -1
  100. package/dist/chunk-DGAVM2LZ.js.map +0 -1
  101. package/dist/chunk-GXSXD4OR.mjs.map +0 -1
  102. package/dist/chunk-UYLS3LJW.js.map +0 -1
  103. /package/dist/{BlockNoteEditor-34AZITDR.mjs.map → BlockNoteEditor-PFCHUFTD.mjs.map} +0 -0
  104. /package/dist/{chunk-2D4MFVDV.mjs.map → chunk-IMSIVIIC.mjs.map} +0 -0
@@ -78,6 +78,7 @@ export function ContentTableSearch({ data }: ContentTableSearchProps) {
78
78
  ) : (
79
79
  <Button
80
80
  data-testid="content-table-search-trigger"
81
+ data-help="list.search"
81
82
  variant="ghost"
82
83
  size="icon-sm"
83
84
  className="text-muted-foreground hover:text-foreground absolute top-0 start-0"
@@ -8,6 +8,7 @@ import type { PartialBlock } from "@blocknote/core";
8
8
  import { BlockNoteView } from "@blocknote/shadcn";
9
9
  import "@blocknote/shadcn/style.css";
10
10
  import { useTheme } from "next-themes";
11
+ import { cn } from "../../utils";
11
12
 
12
13
  function normalize(content: unknown): PartialBlock[] | undefined {
13
14
  if (Array.isArray(content) && content.length > 0) return content as PartialBlock[];
@@ -22,10 +23,20 @@ function normalize(content: unknown): PartialBlock[] | undefined {
22
23
  return undefined;
23
24
  }
24
25
 
25
- export function BlockNoteViewer({ content }: { content: unknown }) {
26
+ export function BlockNoteViewer({ content, size }: { content: unknown; size?: "default" | "sm" }) {
26
27
  const editor = useCreateBlockNote({ initialContent: normalize(content) });
27
28
  // Outside a next-themes ThemeProvider (public pages) resolvedTheme is
28
29
  // undefined, so this falls back to "light".
29
30
  const { resolvedTheme } = useTheme();
30
- return <BlockNoteView editor={editor} editable={false} theme={resolvedTheme === "dark" ? "dark" : "light"} />;
31
+ return (
32
+ <BlockNoteView
33
+ editor={editor}
34
+ editable={false}
35
+ theme={resolvedTheme === "dark" ? "dark" : "light"}
36
+ // Same two classes BlockNoteEditor applies for its `size` prop: the app
37
+ // stylesheet keys the compact recipe off `.BlockNoteView.small`, so a
38
+ // viewer that omits them can only ever render at reading-page size.
39
+ className={cn("BlockNoteView", size === "sm" && "small")}
40
+ />
41
+ );
31
42
  }
@@ -5,6 +5,9 @@ import React from "react";
5
5
 
6
6
  const BlockNoteViewer = dynamic(() => import("./BlockNoteViewer").then((m) => m.BlockNoteViewer), { ssr: false });
7
7
 
8
- export const BlockNoteViewerContainer = React.memo(function ViewerContainer(props: { content: unknown }) {
8
+ export const BlockNoteViewerContainer = React.memo(function ViewerContainer(props: {
9
+ content: unknown;
10
+ size?: "default" | "sm";
11
+ }) {
9
12
  return <BlockNoteViewer {...props} />;
10
13
  });
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { createContext, ReactNode, useContext } from "react";
4
+
5
+ /**
6
+ * True while rendering inside the page action bar (`title.actionBar`, drawn by
7
+ * RoundPageContainerTitle).
8
+ *
9
+ * The action bar is a full-width row of page-level commands, so its buttons
10
+ * carry a visible label — "Edit", "Delete" — instead of the bare glyph the same
11
+ * components use in a table row or a sheet header, where horizontal space is
12
+ * scarce. Commands read the flag through `useIsInActionBar()` rather than
13
+ * taking a prop, so an app only has to populate `title.actionBar`: everything
14
+ * it puts there labels itself, and nothing has to be threaded through the
15
+ * app's own wrapper components.
16
+ */
17
+ const ActionBarContext = createContext<boolean>(false);
18
+
19
+ export const ActionBarProvider = ({ value, children }: { value: boolean; children: ReactNode }) => (
20
+ <ActionBarContext.Provider value={value}>{children}</ActionBarContext.Provider>
21
+ );
22
+
23
+ export const useIsInActionBar = (): boolean => useContext(ActionBarContext);
@@ -7,6 +7,7 @@ export * from "../features/user/contexts";
7
7
  export * from "../features/how-to/contexts/HowToContext";
8
8
  export * from "../features/rbac/contexts/RbacContext";
9
9
  export * from "../features/assistant/contexts/AssistantContext";
10
+ export * from "./ActionBarContext";
10
11
  export * from "./CommonContext";
11
12
  export * from "./DirectionContext";
12
13
  export * from "./HeaderChildrenContext";
@@ -1,3 +1,5 @@
1
1
  export * from "./containers/AssistantContainer";
2
2
  export * from "./containers/AssistantPageContainer";
3
3
  export * from "./parts/AssistantBlockNoteComposer";
4
+ export * from "./parts/AssistantThread";
5
+ export * from "./parts/AssistantComposer";
@@ -47,7 +47,7 @@ export function AssistantComposer({ onSend, disabled, value: controlled, onValue
47
47
  className="min-h-[48px] resize-none border-0 bg-transparent focus-visible:ring-0"
48
48
  />
49
49
  <Button onClick={submit} disabled={!canSend} size="sm" className="h-8">
50
- <ArrowUp className="me-1 h-4 w-4" /> {t("ui.buttons.save")}
50
+ <ArrowUp className="me-1 h-4 w-4" /> {t("features.assistant.send")}
51
51
  </Button>
52
52
  </div>
53
53
  <div className="text-muted-foreground text-end text-xs">{t("features.assistant.keyboard_hint")}</div>
@@ -25,8 +25,8 @@ describe("AssistantComposer", () => {
25
25
 
26
26
  it("Send is disabled on empty/whitespace", () => {
27
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 });
28
+ // The send button renders the raw features.assistant.send key under the test intl stub.
29
+ const send = screen.getByRole("button", { name: /features\.assistant\.send/i });
30
30
  expect(send).toBeDisabled();
31
31
  });
32
32
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { ShieldAlert } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
- import { useEffect, useState } from "react";
5
+ import { Fragment, useEffect, useMemo, useState } from "react";
6
6
  import { MicroLabel } from "../../../components/typography";
7
7
  import { Badge, Button } from "../../../shadcnui";
8
8
  import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
@@ -35,27 +35,278 @@ const STATUS_LABEL_KEY: Record<AssistantActionStatus, string> = {
35
35
  expired: "features.assistant.approval.status.expired",
36
36
  };
37
37
 
38
+ /**
39
+ * One referenced record, already resolved to a display name by the API.
40
+ * `id` exists for React keys only — it is NEVER rendered.
41
+ */
42
+ interface ProposalRef {
43
+ id: string;
44
+ type: string;
45
+ label: string;
46
+ }
47
+
48
+ /**
49
+ * The operator's presentation payload (`AssistantAction.proposal`). Every
50
+ * referenced record arrives resolved to a name, so the card never has to show
51
+ * an id — an unknown or out-of-scope record is resolved to "(not found)" by
52
+ * the API.
53
+ */
54
+ interface OperatorActionProposal {
55
+ /** JSON:API type of the record being written. */
56
+ type: string;
57
+ /** update / delete / link / unlink: the target record. Absent on create. */
58
+ target?: ProposalRef;
59
+ /** create / update: the field values being written. */
60
+ attributes?: Record<string, unknown>;
61
+ /** create: related records, resolved. The run's scope relationship is stripped by the API. */
62
+ relationships?: Record<string, ProposalRef[]>;
63
+ /** link / unlink: the relationship name… */
64
+ relationship?: string;
65
+ /** …and the records being linked or unlinked. */
66
+ targets?: ProposalRef[];
67
+ }
68
+
69
+ /**
70
+ * A parsed `toolArgs` payload, used only as the legacy fallback for actions
71
+ * created before `proposal` existed.
72
+ *
73
+ * NOTE on the key name: `create_entity` / `update_entity` declare the field
74
+ * values under `fields` (see `agents/operator/tools/entity-write.tools.ts`),
75
+ * while other producers use the JSON:API-shaped `attributes`. Both are read so
76
+ * the card never silently degrades to the bare summary. Relationships are NOT
77
+ * read from here: `toolArgs` carries ids only, and no id is ever rendered.
78
+ */
79
+ interface LegacyProposal {
80
+ type: string;
81
+ attributes: Record<string, unknown>;
82
+ }
83
+
84
+ interface ProposalRow {
85
+ key: string;
86
+ label: string;
87
+ value: string;
88
+ }
89
+
90
+ /**
91
+ * Entity label from a JSON:API type: drop a trailing "s", upper-case the first
92
+ * letter. Short types (three letters or fewer after singularising) are
93
+ * acronyms in this domain — "npcs" → "NPC", "pcs" → "PC" — so they are
94
+ * upper-cased whole.
95
+ */
96
+ function humaniseType(type: string): string {
97
+ const singular = type.endsWith("s") ? type.slice(0, -1) : type;
98
+ if (singular.length <= 3) return singular.toUpperCase();
99
+ return singular.charAt(0).toUpperCase() + singular.slice(1);
100
+ }
101
+
102
+ /** `tldr` → `Tldr`, `publicDescription` → `Public description`. */
103
+ function humaniseKey(key: string): string {
104
+ const words = key
105
+ .replace(/[_-]+/g, " ")
106
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
107
+ .trim();
108
+ return words.charAt(0).toUpperCase() + words.slice(1);
109
+ }
110
+
111
+ /**
112
+ * Plain text of a BlockNote document, without importing BlockNote: walk the
113
+ * blocks, concatenate the text runs of each block's `content`, recurse into
114
+ * `children`, and join the blocks with newlines.
115
+ */
116
+ function blockNoteText(blocks: unknown[]): string {
117
+ const lines: string[] = [];
118
+ const walk = (nodes: unknown[]) => {
119
+ for (const node of nodes) {
120
+ if (!node || typeof node !== "object") continue;
121
+ const block = node as { content?: unknown; children?: unknown };
122
+ const runs = Array.isArray(block.content)
123
+ ? block.content
124
+ .map((run) =>
125
+ run && typeof run === "object" && typeof (run as { text?: unknown }).text === "string"
126
+ ? (run as { text: string }).text
127
+ : "",
128
+ )
129
+ .join("")
130
+ : "";
131
+ if (runs.trim()) lines.push(runs);
132
+ if (Array.isArray(block.children)) walk(block.children);
133
+ }
134
+ };
135
+ walk(blocks);
136
+ return lines.join("\n");
137
+ }
138
+
139
+ /** Returns the blocks when `value` parses as a BlockNote document (an array of `{ type }` blocks). */
140
+ function asBlockNoteDocument(value: string): unknown[] | undefined {
141
+ if (!value.trimStart().startsWith("[")) return undefined;
142
+ try {
143
+ const parsed: unknown = JSON.parse(value);
144
+ if (!Array.isArray(parsed) || parsed.length === 0) return undefined;
145
+ const everyBlock = parsed.every(
146
+ (block) => !!block && typeof block === "object" && typeof (block as { type?: unknown }).type === "string",
147
+ );
148
+ return everyBlock ? parsed : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ /** Renders one attribute value as text. `undefined` means "skip this row". */
155
+ function formatAttribute(value: unknown, yes: string, no: string): string | undefined {
156
+ if (value === null || value === undefined) return undefined;
157
+ if (typeof value === "boolean") return value ? yes : no;
158
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
159
+ if (Array.isArray(value)) {
160
+ const joined = value
161
+ .map((entry) =>
162
+ entry === null || entry === undefined ? "" : typeof entry === "object" ? JSON.stringify(entry) : String(entry),
163
+ )
164
+ .filter((entry) => entry !== "")
165
+ .join(", ");
166
+ return joined === "" ? undefined : joined;
167
+ }
168
+ if (typeof value === "string") {
169
+ if (value.trim() === "") return undefined;
170
+ const document = asBlockNoteDocument(value);
171
+ if (!document) return value;
172
+ const text = blockNoteText(document);
173
+ return text.trim() === "" ? undefined : text;
174
+ }
175
+ if (typeof value === "object") {
176
+ const serialised = JSON.stringify(value);
177
+ return serialised === "{}" ? undefined : serialised;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** Joins the display names of resolved records. `undefined` means "skip this row". */
183
+ function formatRefs(refs: ProposalRef[]): string | undefined {
184
+ const labels = refs.map((ref) => ref.label).filter((label) => label.trim() !== "");
185
+ return labels.length ? labels.join(", ") : undefined;
186
+ }
187
+
188
+ /** A `{ id, type, label }` record, or `undefined` when the entry is not one. */
189
+ function asRef(value: unknown): ProposalRef | undefined {
190
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
191
+ const { id, type, label } = value as { id?: unknown; type?: unknown; label?: unknown };
192
+ if (typeof label !== "string") return undefined;
193
+ return {
194
+ id: typeof id === "string" ? id : "",
195
+ type: typeof type === "string" ? type : "",
196
+ label,
197
+ };
198
+ }
199
+
200
+ function asRefs(value: unknown): ProposalRef[] {
201
+ if (!Array.isArray(value)) return [];
202
+ return value.flatMap((entry) => {
203
+ const ref = asRef(entry);
204
+ return ref ? [ref] : [];
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Parses the action's `proposal` — the API's presentation payload, where every
210
+ * referenced record already carries its display name. Returns `undefined` when
211
+ * the payload is absent or unparseable, so the card can fall back to the
212
+ * legacy `toolArgs` attributes.
213
+ */
214
+ function parseOperatorProposal(proposal: string | undefined): OperatorActionProposal | undefined {
215
+ if (!proposal) return undefined;
216
+ let parsed: unknown;
217
+ try {
218
+ parsed = JSON.parse(proposal);
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
223
+
224
+ const payload = parsed as Record<string, unknown>;
225
+ const attributes =
226
+ payload.attributes && typeof payload.attributes === "object" && !Array.isArray(payload.attributes)
227
+ ? (payload.attributes as Record<string, unknown>)
228
+ : undefined;
229
+
230
+ const relationships: Record<string, ProposalRef[]> = {};
231
+ if (payload.relationships && typeof payload.relationships === "object" && !Array.isArray(payload.relationships)) {
232
+ for (const [key, value] of Object.entries(payload.relationships as Record<string, unknown>)) {
233
+ relationships[key] = asRefs(value);
234
+ }
235
+ }
236
+
237
+ return {
238
+ type: typeof payload.type === "string" ? payload.type : "",
239
+ target: asRef(payload.target),
240
+ attributes,
241
+ relationships,
242
+ relationship: typeof payload.relationship === "string" ? payload.relationship : undefined,
243
+ targets: asRefs(payload.targets),
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Legacy fallback for actions created before `proposal` existed: parses the
249
+ * field values out of `toolArgs`. Returns `undefined` whenever the payload is
250
+ * absent, unparseable or carries no field values — the card then falls back to
251
+ * the summary alone, which must always still render with its buttons.
252
+ *
253
+ * Relationships are deliberately ignored here: `toolArgs` names them by id
254
+ * only, and an id must never reach the screen.
255
+ */
256
+ function parseLegacyProposal(toolArgs: string | undefined): LegacyProposal | undefined {
257
+ if (!toolArgs) return undefined;
258
+ let parsed: unknown;
259
+ try {
260
+ parsed = JSON.parse(toolArgs);
261
+ } catch {
262
+ return undefined;
263
+ }
264
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
265
+
266
+ const payload = parsed as Record<string, unknown>;
267
+ const attributes = payload.attributes ?? payload.fields;
268
+ if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) return undefined;
269
+
270
+ return {
271
+ type: typeof payload.type === "string" ? payload.type : "",
272
+ attributes: attributes as Record<string, unknown>,
273
+ };
274
+ }
275
+
38
276
  interface Props {
39
277
  /** Id of the pending AssistantAction linked to the approval-request message. */
40
278
  actionId: string;
41
279
  /** Human-readable summary fallback (the approval-request message content). */
42
280
  summary?: string;
281
+ /**
282
+ * Relationship keys from the proposal that must not be rendered as rows.
283
+ *
284
+ * The write tools discard the model's scope relationship and force the run's
285
+ * own scope, so showing it is noise at best and wrong at worst (the proposal
286
+ * may name a different record than the one that will actually be written).
287
+ * The host app names the keys it forces — e.g. `["campaign"]`.
288
+ */
289
+ hiddenRelationshipKeys?: string[];
43
290
  /** Invoked with the resumed assistant message returned by approve/deny. */
44
291
  onResolved?: (message: AssistantMessageInterface) => void;
45
292
  }
46
293
 
47
294
  /**
48
295
  * 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`.
296
+ * the destructive-tool summary, the proposed record (what the assistant wants
297
+ * to write), plus Approve / Deny buttons. Buttons are only actionable while the
298
+ * underlying AssistantAction is `pending`.
51
299
  *
52
300
  * NOTE: plain `<Button>` elements on purpose — never wrapped inside any
53
301
  * Base UI trigger component (no nested buttons, no `asChild`).
54
302
  */
55
- export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
303
+ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelationshipKeys }: Props) {
56
304
  const t = useTranslations();
57
305
  const [status, setStatus] = useState<AssistantActionStatus | undefined>(undefined);
58
306
  const [actionSummary, setActionSummary] = useState<string | undefined>(undefined);
307
+ const [toolName, setToolName] = useState<string | undefined>(undefined);
308
+ const [toolArgs, setToolArgs] = useState<string | undefined>(undefined);
309
+ const [actionProposal, setActionProposal] = useState<string | undefined>(undefined);
59
310
  const [busy, setBusy] = useState<boolean>(false);
60
311
  const [failed, setFailed] = useState<boolean>(false);
61
312
 
@@ -67,6 +318,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
67
318
  if (cancelled || !action) return;
68
319
  setStatus(action.status);
69
320
  setActionSummary(action.summary);
321
+ setToolName(action.toolName);
322
+ setToolArgs(action.toolArgs);
323
+ setActionProposal(action.proposal);
70
324
  } catch (error) {
71
325
  console.error(`ApprovalActionCard: failed to fetch assistant action ${actionId}`, error);
72
326
  if (!cancelled) setFailed(true);
@@ -77,6 +331,53 @@ export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
77
331
  };
78
332
  }, [actionId]);
79
333
 
334
+ // The API's resolved payload wins; `toolArgs` is only the legacy fallback
335
+ // (attributes alone — it names related records by id, which is never shown).
336
+ const presented = useMemo(() => parseOperatorProposal(actionProposal), [actionProposal]);
337
+ const legacy = useMemo(() => (presented ? undefined : parseLegacyProposal(toolArgs)), [presented, toolArgs]);
338
+ const isUpdate = toolName === "update_entity";
339
+ const entityLabel = humaniseType(presented?.type ?? legacy?.type ?? "");
340
+ const targetLabel = presented?.target?.label ?? "";
341
+
342
+ const rows = useMemo<ProposalRow[]>(() => {
343
+ const yes = t("features.assistant.approval.proposal.yes");
344
+ const no = t("features.assistant.approval.proposal.no");
345
+ const attributes = presented?.attributes ?? legacy?.attributes;
346
+ const attributeRows = Object.entries(attributes ?? {}).flatMap<ProposalRow>(([key, value]) => {
347
+ const text = formatAttribute(value, yes, no);
348
+ return text === undefined ? [] : [{ key: `attribute:${key}`, label: humaniseKey(key), value: text }];
349
+ });
350
+ if (!presented) return attributeRows;
351
+
352
+ const hidden = new Set(hiddenRelationshipKeys ?? []);
353
+ const relationshipRows = Object.entries(presented.relationships ?? {}).flatMap<ProposalRow>(([key, refs]) => {
354
+ if (hidden.has(key)) return [];
355
+ const text = formatRefs(refs);
356
+ return text === undefined ? [] : [{ key: `relationship:${key}`, label: humaniseKey(key), value: text }];
357
+ });
358
+
359
+ const linkRows: ProposalRow[] = [];
360
+ if (presented.relationship) {
361
+ linkRows.push({
362
+ key: "link:relationship",
363
+ label: t("features.assistant.approval.proposal.relationship"),
364
+ value: humaniseKey(presented.relationship),
365
+ });
366
+ }
367
+ const targetsText = formatRefs(presented.targets ?? []);
368
+ if (targetsText !== undefined) {
369
+ linkRows.push({
370
+ key: "link:records",
371
+ label: t("features.assistant.approval.proposal.records"),
372
+ value: targetsText,
373
+ });
374
+ }
375
+
376
+ return [...attributeRows, ...relationshipRows, ...linkRows];
377
+ }, [presented, legacy, t, hiddenRelationshipKeys]);
378
+
379
+ const hasProposal = rows.length > 0;
380
+
80
381
  const actionable = status === "pending" && !busy && !failed;
81
382
 
82
383
  const resolve = async (kind: "approve" | "deny") => {
@@ -109,6 +410,26 @@ export function ApprovalActionCard({ actionId, summary, onResolved }: Props) {
109
410
  )}
110
411
  </MicroLabel>
111
412
  <div className="text-foreground text-sm leading-relaxed">{actionSummary ?? summary}</div>
413
+ {hasProposal && (
414
+ <div className="border-border/60 flex flex-col gap-2 rounded-lg border border-dashed p-3">
415
+ <MicroLabel className="text-muted-foreground flex flex-wrap items-baseline gap-x-2">
416
+ <span>
417
+ {isUpdate
418
+ ? t("features.assistant.approval.proposal.updateHeading", { entity: entityLabel })
419
+ : t("features.assistant.approval.proposal.heading", { entity: entityLabel })}
420
+ </span>
421
+ {targetLabel !== "" && <span className="text-foreground tracking-normal normal-case">{targetLabel}</span>}
422
+ </MicroLabel>
423
+ <dl className="grid grid-cols-1 gap-x-4 gap-y-1.5 sm:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]">
424
+ {rows.map((row) => (
425
+ <Fragment key={row.key}>
426
+ <dt className="text-muted-foreground text-xs leading-relaxed break-words sm:text-sm">{row.label}</dt>
427
+ <dd className="text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap">{row.value}</dd>
428
+ </Fragment>
429
+ ))}
430
+ </dl>
431
+ </div>
432
+ )}
112
433
  {failed && (
113
434
  <div role="alert" className="text-destructive text-xs/relaxed">
114
435
  {t("features.assistant.approval.error")}