@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.
- package/dist/{BlockNoteEditor-KRGDA5RX.js → BlockNoteEditor-4UAIVF3G.js} +19 -19
- package/dist/{BlockNoteEditor-KRGDA5RX.js.map → BlockNoteEditor-4UAIVF3G.js.map} +1 -1
- package/dist/BlockNoteEditor-K55A4FS2.css +30 -0
- package/dist/BlockNoteEditor-K55A4FS2.css.map +1 -0
- package/dist/{BlockNoteEditor-34AZITDR.mjs → BlockNoteEditor-PFCHUFTD.mjs} +4 -4
- package/dist/{BlockNoteViewer-6N2GQWTI.js → BlockNoteViewer-C2UUOBAF.js} +17 -3
- package/dist/BlockNoteViewer-C2UUOBAF.js.map +1 -0
- package/dist/{BlockNoteViewer-IBQRJ3WM.mjs → BlockNoteViewer-TYLVTIUD.mjs} +17 -3
- package/dist/BlockNoteViewer-TYLVTIUD.mjs.map +1 -0
- package/dist/billing/index.css +22 -21
- package/dist/billing/index.css.map +1 -1
- package/dist/billing/index.js +398 -398
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-DGAVM2LZ.js → chunk-46CQ3RRQ.js} +1315 -1069
- package/dist/chunk-46CQ3RRQ.js.map +1 -0
- package/dist/{chunk-UYLS3LJW.js → chunk-6J2SQZ3G.js} +11 -1
- package/dist/chunk-6J2SQZ3G.js.map +1 -0
- package/dist/{chunk-2D4MFVDV.mjs → chunk-IMSIVIIC.mjs} +2 -2
- package/dist/{chunk-HDZ2BL6D.mjs → chunk-M5QAHSCY.mjs} +11 -1
- package/dist/{chunk-HDZ2BL6D.mjs.map → chunk-M5QAHSCY.mjs.map} +1 -1
- package/dist/{chunk-TULJCLZA.js → chunk-V6CGCARD.js} +7 -7
- package/dist/{chunk-TULJCLZA.js.map → chunk-V6CGCARD.js.map} +1 -1
- package/dist/{chunk-GXSXD4OR.mjs → chunk-XZJJ4SZW.mjs} +2073 -1827
- package/dist/chunk-XZJJ4SZW.mjs.map +1 -0
- package/dist/client/index.css +22 -21
- package/dist/client/index.css.map +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.css +22 -21
- package/dist/components/index.css.map +1 -1
- package/dist/components/index.d.mts +77 -15
- package/dist/components/index.d.ts +77 -15
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.css +22 -21
- package/dist/contexts/index.css.map +1 -1
- package/dist/contexts/index.d.mts +17 -4
- package/dist/contexts/index.d.ts +17 -4
- package/dist/contexts/index.js +10 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +9 -3
- package/dist/core/index.d.mts +13 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.css +22 -21
- package/dist/features/help/index.css.map +1 -1
- package/dist/features/help/index.d.mts +6 -1
- package/dist/features/help/index.d.ts +6 -1
- package/dist/features/help/index.js +39 -38
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +4 -3
- package/dist/features/tokenusage/index.css +22 -21
- package/dist/features/tokenusage/index.css.map +1 -1
- package/dist/features/tokenusage/index.js +102 -102
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{onboarding.interface-Djyl9qYu.d.mts → onboarding.interface-DVUOUC4y.d.mts} +4 -1
- package/dist/{onboarding.interface-Djyl9qYu.d.ts → onboarding.interface-DVUOUC4y.d.ts} +4 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +2 -2
- package/src/components/containers/RoundPageContainer.tsx +38 -16
- package/src/components/containers/RoundPageContainerTitle.tsx +11 -3
- package/src/components/containers/TabsContainer.tsx +11 -0
- package/src/components/forms/CommonDeleter.tsx +14 -1
- package/src/components/forms/EditorSheet.tsx +66 -47
- package/src/components/grids/ContentListGrid.tsx +1 -1
- package/src/components/navigations/MobileNavigationBar.tsx +1 -0
- package/src/components/tables/ContentListTable.tsx +1 -1
- package/src/components/tables/ContentTableSearch.tsx +1 -0
- package/src/components/viewers/BlockNoteViewer.tsx +13 -2
- package/src/components/viewers/BlockNoteViewerContainer.tsx +4 -1
- package/src/contexts/ActionBarContext.tsx +23 -0
- package/src/contexts/index.ts +1 -0
- package/src/features/assistant/components/index.ts +2 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +2 -2
- package/src/features/assistant-action/components/ApprovalActionCard.tsx +325 -4
- package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +268 -0
- package/src/features/assistant-action/data/AssistantAction.ts +12 -0
- package/src/features/assistant-action/data/AssistantActionInterface.ts +9 -0
- package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +11 -0
- package/src/features/help/index.ts +1 -0
- package/src/features/onboarding/components/OnboardingCard.tsx +1 -2
- package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +191 -0
- package/src/features/onboarding/contexts/OnboardingContext.tsx +176 -125
- package/src/features/onboarding/interfaces/onboarding.interface.ts +4 -0
- package/src/features/onboarding/styles/onboarding.css +25 -30
- package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +1 -1
- package/src/shadcnui/ui/command.tsx +9 -0
- package/dist/BlockNoteEditor-YOAJRPWU.css +0 -29
- package/dist/BlockNoteEditor-YOAJRPWU.css.map +0 -1
- package/dist/BlockNoteViewer-6N2GQWTI.js.map +0 -1
- package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +0 -1
- package/dist/chunk-DGAVM2LZ.js.map +0 -1
- package/dist/chunk-GXSXD4OR.mjs.map +0 -1
- package/dist/chunk-UYLS3LJW.js.map +0 -1
- /package/dist/{BlockNoteEditor-34AZITDR.mjs.map → BlockNoteEditor-PFCHUFTD.mjs.map} +0 -0
- /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
|
|
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: {
|
|
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);
|
package/src/contexts/index.ts
CHANGED
|
@@ -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";
|
|
@@ -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("
|
|
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
|
-
//
|
|
29
|
-
const send = screen.getByRole("button", { name: /
|
|
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
|
|
50
|
-
* only actionable while the
|
|
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")}
|