@carlonicora/nextjs-jsonapi 3.9.0 → 3.11.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-K55A4FS2.css +30 -0
  2. package/dist/BlockNoteEditor-K55A4FS2.css.map +1 -0
  3. package/dist/{BlockNoteEditor-RGMU2O24.mjs → BlockNoteEditor-NBHDEZ7W.mjs} +4 -4
  4. package/dist/{BlockNoteEditor-WY4LFMXQ.js → BlockNoteEditor-XOVNTXDY.js} +19 -19
  5. package/dist/{BlockNoteEditor-WY4LFMXQ.js.map → BlockNoteEditor-XOVNTXDY.js.map} +1 -1
  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-UYLS3LJW.js → chunk-6J2SQZ3G.js} +11 -1
  15. package/dist/chunk-6J2SQZ3G.js.map +1 -0
  16. package/dist/{chunk-2D4MFVDV.mjs → chunk-IMSIVIIC.mjs} +2 -2
  17. package/dist/{chunk-HDZ2BL6D.mjs → chunk-M5QAHSCY.mjs} +11 -1
  18. package/dist/{chunk-HDZ2BL6D.mjs.map → chunk-M5QAHSCY.mjs.map} +1 -1
  19. package/dist/{chunk-TULJCLZA.js → chunk-V6CGCARD.js} +7 -7
  20. package/dist/{chunk-TULJCLZA.js.map → chunk-V6CGCARD.js.map} +1 -1
  21. package/dist/{chunk-7PNGCZES.mjs → chunk-XNGI4HDZ.mjs} +1529 -1286
  22. package/dist/chunk-XNGI4HDZ.mjs.map +1 -0
  23. package/dist/{chunk-DOLEXUO3.js → chunk-YN4LOVL4.js} +1301 -1058
  24. package/dist/chunk-YN4LOVL4.js.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 +46 -14
  32. package/dist/components/index.d.ts +46 -14
  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 +28 -4
  39. package/dist/contexts/index.d.ts +28 -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 +2 -1
  68. package/src/components/containers/RoundPageContainerTitle.tsx +6 -1
  69. package/src/components/containers/TabsContainer.tsx +1 -0
  70. package/src/components/forms/EditorSheet.tsx +2 -1
  71. package/src/components/grids/ContentListGrid.tsx +1 -1
  72. package/src/components/navigations/Breadcrumb.tsx +21 -9
  73. package/src/components/navigations/Header.tsx +3 -1
  74. package/src/components/navigations/MobileNavigationBar.tsx +1 -0
  75. package/src/components/tables/ContentListTable.tsx +1 -1
  76. package/src/components/tables/ContentTableSearch.tsx +1 -0
  77. package/src/components/viewers/BlockNoteViewer.tsx +13 -2
  78. package/src/components/viewers/BlockNoteViewerContainer.tsx +4 -1
  79. package/src/contexts/BreadcrumbRootContext.tsx +28 -0
  80. package/src/contexts/index.ts +1 -0
  81. package/src/features/assistant/components/index.ts +2 -0
  82. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  83. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +2 -2
  84. package/src/features/assistant-action/components/ApprovalActionCard.tsx +325 -4
  85. package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +268 -0
  86. package/src/features/assistant-action/data/AssistantAction.ts +12 -0
  87. package/src/features/assistant-action/data/AssistantActionInterface.ts +9 -0
  88. package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +11 -0
  89. package/src/features/help/index.ts +1 -0
  90. package/src/features/onboarding/components/OnboardingCard.tsx +1 -2
  91. package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +191 -0
  92. package/src/features/onboarding/contexts/OnboardingContext.tsx +176 -125
  93. package/src/features/onboarding/interfaces/onboarding.interface.ts +4 -0
  94. package/src/features/onboarding/styles/onboarding.css +25 -30
  95. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +1 -1
  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-7PNGCZES.mjs.map +0 -1
  101. package/dist/chunk-DOLEXUO3.js.map +0 -1
  102. package/dist/chunk-UYLS3LJW.js.map +0 -1
  103. /package/dist/{BlockNoteEditor-RGMU2O24.mjs.map → BlockNoteEditor-NBHDEZ7W.mjs.map} +0 -0
  104. /package/dist/{chunk-2D4MFVDV.mjs.map → chunk-IMSIVIIC.mjs.map} +0 -0
@@ -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")}
@@ -0,0 +1,268 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ApprovalActionCard } from "../ApprovalActionCard";
4
+
5
+ const findOne = vi.fn();
6
+ const approve = vi.fn();
7
+ const deny = vi.fn();
8
+
9
+ vi.mock("../../data/AssistantActionService", () => ({
10
+ AssistantActionService: {
11
+ findOne: (params: { id: string }) => findOne(params),
12
+ approve: (params: { id: string }) => approve(params),
13
+ deny: (params: { id: string }) => deny(params),
14
+ },
15
+ }));
16
+
17
+ /** Any uuid-ish run of hex — no id may ever reach the screen. */
18
+ const UUID_LIKE = /[0-9a-f]{8}-[0-9a-f]{4}-/;
19
+
20
+ const CREATE_TOOL_ARGS = JSON.stringify({
21
+ type: "npcs",
22
+ attributes: { name: "Marcus", tldr: "A nerd with bouts of rage" },
23
+ relationships: { related: { data: [{ type: "npcs", id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f" }] } },
24
+ });
25
+
26
+ const CREATE_PROPOSAL = JSON.stringify({
27
+ type: "npcs",
28
+ attributes: { name: "Marcus", tldr: "A nerd with bouts of rage" },
29
+ relationships: {
30
+ related: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
31
+ },
32
+ });
33
+
34
+ function makeAction(overrides: Record<string, unknown> = {}) {
35
+ return {
36
+ id: "act-1",
37
+ status: "pending",
38
+ toolName: "create_entity",
39
+ toolArgs: CREATE_TOOL_ARGS,
40
+ proposal: CREATE_PROPOSAL,
41
+ summary: 'Create a new npcs record named "Marcus".',
42
+ ...overrides,
43
+ } as any;
44
+ }
45
+
46
+ describe("ApprovalActionCard", () => {
47
+ beforeEach(() => {
48
+ findOne.mockReset();
49
+ approve.mockReset();
50
+ deny.mockReset();
51
+ });
52
+
53
+ it("renders the proposed record: the attributes and the related records by name", async () => {
54
+ findOne.mockResolvedValue(makeAction());
55
+
56
+ render(<ApprovalActionCard actionId="act-1" />);
57
+
58
+ // summary still renders
59
+ expect(await screen.findByText('Create a new npcs record named "Marcus".')).toBeInTheDocument();
60
+ // attributes
61
+ expect(await screen.findByText("Marcus")).toBeInTheDocument();
62
+ expect(screen.getByText("A nerd with bouts of rage")).toBeInTheDocument();
63
+ expect(screen.getByText("Name")).toBeInTheDocument();
64
+ // relationships: humanised key + the resolved display names, never ids
65
+ expect(screen.getByText("Related")).toBeInTheDocument();
66
+ expect(screen.getByText("Zoe")).toBeInTheDocument();
67
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
68
+ // buttons are actionable while the action is pending
69
+ expect(screen.getByRole("button", { name: "features.assistant.approval.approve" })).toBeEnabled();
70
+ expect(screen.getByRole("button", { name: "features.assistant.approval.deny" })).toBeEnabled();
71
+ });
72
+
73
+ it("renders booleans as Yes/No and numbers as-is", async () => {
74
+ findOne.mockResolvedValue(
75
+ makeAction({
76
+ proposal: JSON.stringify({
77
+ type: "factions",
78
+ attributes: { name: "The Cinder Pact", secret: true, rank: 3 },
79
+ }),
80
+ }),
81
+ );
82
+
83
+ render(<ApprovalActionCard actionId="act-1" />);
84
+
85
+ expect(await screen.findByText("The Cinder Pact")).toBeInTheDocument();
86
+ expect(screen.getByText("features.assistant.approval.proposal.yes")).toBeInTheDocument();
87
+ expect(screen.getByText("3")).toBeInTheDocument();
88
+ });
89
+
90
+ it("renders the plain text of a BlockNote document value", async () => {
91
+ const document = JSON.stringify([
92
+ { id: "1", type: "paragraph", content: [{ type: "text", text: "First line" }], children: [] },
93
+ {
94
+ id: "2",
95
+ type: "paragraph",
96
+ content: [
97
+ { type: "text", text: "Second " },
98
+ { type: "text", text: "line" },
99
+ ],
100
+ },
101
+ ]);
102
+ findOne.mockResolvedValue(
103
+ makeAction({ proposal: JSON.stringify({ type: "npcs", attributes: { description: document } }) }),
104
+ );
105
+
106
+ render(<ApprovalActionCard actionId="act-1" />);
107
+
108
+ // identity normalizer: the default one collapses the newline the reducer emits
109
+ expect(await screen.findByText("First line\nSecond line", { normalizer: (text) => text })).toBeInTheDocument();
110
+ });
111
+
112
+ it("skips null, undefined and empty attribute rows", async () => {
113
+ findOne.mockResolvedValue(
114
+ makeAction({
115
+ proposal: JSON.stringify({
116
+ type: "npcs",
117
+ attributes: { name: "Marcus", tldr: null, notes: "", tags: [] },
118
+ }),
119
+ }),
120
+ );
121
+
122
+ render(<ApprovalActionCard actionId="act-1" />);
123
+
124
+ expect(await screen.findByText("Marcus")).toBeInTheDocument();
125
+ expect(screen.queryByText("Tldr")).not.toBeInTheDocument();
126
+ expect(screen.queryByText("Notes")).not.toBeInTheDocument();
127
+ expect(screen.queryByText("Tags")).not.toBeInTheDocument();
128
+ });
129
+
130
+ it("shows the target record's name — never its id — for an update_entity proposal", async () => {
131
+ findOne.mockResolvedValue(
132
+ makeAction({
133
+ toolName: "update_entity",
134
+ summary: 'Update the npcs record "Marcus" (name).',
135
+ toolArgs: JSON.stringify({
136
+ type: "npcs",
137
+ id: "npc-9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c",
138
+ attributes: { name: "Marcus the Calm" },
139
+ }),
140
+ proposal: JSON.stringify({
141
+ type: "npcs",
142
+ target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
143
+ attributes: { name: "Marcus the Calm" },
144
+ }),
145
+ }),
146
+ );
147
+
148
+ render(<ApprovalActionCard actionId="act-1" />);
149
+
150
+ expect(await screen.findByText("features.assistant.approval.proposal.updateHeading")).toBeInTheDocument();
151
+ expect(screen.getByText("Marcus")).toBeInTheDocument();
152
+ expect(screen.getByText("Marcus the Calm")).toBeInTheDocument();
153
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
154
+ });
155
+
156
+ it("shows the relationship name and the linked records for a link proposal", async () => {
157
+ findOne.mockResolvedValue(
158
+ makeAction({
159
+ toolName: "link_entities",
160
+ summary: 'Link "Zoe", "Bob" to the "related" relationship of the npcs record "Marcus".',
161
+ proposal: JSON.stringify({
162
+ type: "npcs",
163
+ target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
164
+ relationship: "related",
165
+ targets: [
166
+ { id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" },
167
+ { id: "c81d6ac7-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Bob" },
168
+ ],
169
+ }),
170
+ }),
171
+ );
172
+
173
+ render(<ApprovalActionCard actionId="act-1" />);
174
+
175
+ expect(await screen.findByText("features.assistant.approval.proposal.relationship")).toBeInTheDocument();
176
+ expect(screen.getByText("Related")).toBeInTheDocument();
177
+ expect(screen.getByText("features.assistant.approval.proposal.records")).toBeInTheDocument();
178
+ expect(screen.getByText("Zoe, Bob")).toBeInTheDocument();
179
+ expect(screen.getByText("Marcus")).toBeInTheDocument();
180
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
181
+ });
182
+
183
+ it("shows the unlinked records by name for an unlink proposal", async () => {
184
+ findOne.mockResolvedValue(
185
+ makeAction({
186
+ toolName: "unlink_entities",
187
+ summary: 'Unlink "Zoe" from the "related" relationship of the npcs record "Marcus".',
188
+ proposal: JSON.stringify({
189
+ type: "npcs",
190
+ target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
191
+ relationship: "related",
192
+ targets: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
193
+ }),
194
+ }),
195
+ );
196
+
197
+ render(<ApprovalActionCard actionId="act-1" />);
198
+
199
+ expect(await screen.findByText("Zoe")).toBeInTheDocument();
200
+ expect(screen.getByText("features.assistant.approval.proposal.records")).toBeInTheDocument();
201
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
202
+ });
203
+
204
+ it("hides the relationship rows named by hiddenRelationshipKeys, and shows them when it does not", async () => {
205
+ // The write tools force the run's own scope and discard the model's, so
206
+ // the host app hides that relationship rather than showing a record the
207
+ // write may not even end up using.
208
+ const proposal = JSON.stringify({
209
+ type: "npcs",
210
+ attributes: { name: "Marcus" },
211
+ relationships: {
212
+ campaign: [{ id: "c81d6ac7-1a2b-4c4d-9a6c-042ec3199d3c", type: "campaigns", label: "The Ashen Crown" }],
213
+ related: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
214
+ },
215
+ });
216
+ findOne.mockResolvedValue(makeAction({ proposal }));
217
+
218
+ const hidden = render(<ApprovalActionCard actionId="act-1" hiddenRelationshipKeys={["campaign"]} />);
219
+
220
+ expect(await screen.findByText("Marcus")).toBeInTheDocument();
221
+ expect(screen.queryByText("Campaign")).not.toBeInTheDocument();
222
+ expect(screen.queryByText("The Ashen Crown")).not.toBeInTheDocument();
223
+ // the relationships that are not listed keep rendering
224
+ expect(screen.getByText("Related")).toBeInTheDocument();
225
+ expect(screen.getByText("Zoe")).toBeInTheDocument();
226
+
227
+ hidden.unmount();
228
+ render(<ApprovalActionCard actionId="act-1" />);
229
+
230
+ expect(await screen.findByText("Campaign")).toBeInTheDocument();
231
+ expect(screen.getByText("The Ashen Crown")).toBeInTheDocument();
232
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
233
+ });
234
+
235
+ it("falls back to the toolArgs attributes — and to no relationship rows — for a legacy action", async () => {
236
+ findOne.mockResolvedValue(
237
+ makeAction({
238
+ proposal: "",
239
+ toolArgs: JSON.stringify({
240
+ type: "npcs",
241
+ fields: { name: "Marcus", tldr: "A nerd with bouts of rage" },
242
+ relationships: { related: { data: [{ type: "npcs", id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f" }] } },
243
+ }),
244
+ }),
245
+ );
246
+
247
+ render(<ApprovalActionCard actionId="act-1" />);
248
+
249
+ // attributes still render (`fields` is the key the create_entity tool emits)
250
+ expect(await screen.findByText("Marcus")).toBeInTheDocument();
251
+ expect(screen.getByText("A nerd with bouts of rage")).toBeInTheDocument();
252
+ // relationships from toolArgs carry ids only — no row, and no id on screen
253
+ expect(screen.queryByText("Related")).not.toBeInTheDocument();
254
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
255
+ });
256
+
257
+ it("still renders the summary and the buttons when neither payload parses", async () => {
258
+ findOne.mockResolvedValue(makeAction({ proposal: "{not-json", toolArgs: "{not-json" }));
259
+
260
+ render(<ApprovalActionCard actionId="act-1" />);
261
+
262
+ expect(await screen.findByText('Create a new npcs record named "Marcus".')).toBeInTheDocument();
263
+ expect(screen.getByRole("button", { name: "features.assistant.approval.approve" })).toBeEnabled();
264
+ expect(screen.getByRole("button", { name: "features.assistant.approval.deny" })).toBeEnabled();
265
+ expect(screen.queryByText("features.assistant.approval.proposal.heading")).not.toBeInTheDocument();
266
+ expect(screen.queryByText(UUID_LIKE)).toBeNull();
267
+ });
268
+ });
@@ -4,6 +4,8 @@ import { AssistantActionInput, AssistantActionInterface, AssistantActionStatus }
4
4
  export class AssistantAction extends AbstractApiData implements AssistantActionInterface {
5
5
  private _status?: AssistantActionStatus;
6
6
  private _toolName?: string;
7
+ private _toolArgs?: string;
8
+ private _proposal?: string;
7
9
  private _summary?: string;
8
10
  private _resolvedAt?: Date;
9
11
  private _expiresAt?: Date;
@@ -17,6 +19,14 @@ export class AssistantAction extends AbstractApiData implements AssistantActionI
17
19
  return this._toolName ?? "";
18
20
  }
19
21
 
22
+ get toolArgs(): string {
23
+ return this._toolArgs ?? "";
24
+ }
25
+
26
+ get proposal(): string {
27
+ return this._proposal ?? "";
28
+ }
29
+
20
30
  get summary(): string {
21
31
  return this._summary ?? "";
22
32
  }
@@ -34,6 +44,8 @@ export class AssistantAction extends AbstractApiData implements AssistantActionI
34
44
 
35
45
  this._status = data.jsonApi.attributes.status;
36
46
  this._toolName = data.jsonApi.attributes.toolName;
47
+ this._toolArgs = data.jsonApi.attributes.toolArgs;
48
+ this._proposal = data.jsonApi.attributes.proposal;
37
49
  this._summary = data.jsonApi.attributes.summary;
38
50
  this._resolvedAt = data.jsonApi.attributes.resolvedAt ? new Date(data.jsonApi.attributes.resolvedAt) : undefined;
39
51
  this._expiresAt = data.jsonApi.attributes.expiresAt ? new Date(data.jsonApi.attributes.expiresAt) : undefined;