@simmalugnt-se/payload-editor-assistant 0.11.3 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ - Proposals are grouped by block, row and named group, in document order, with collapsible sections
6
+ and field labels directly attached to their suggested values. Batches and ordinary form proposals
7
+ use the same review layout.
8
+ - Rich text proposals show a full formatted preview of the compiled Lexical content, including
9
+ headings, lists and text formatting. Current content can be expanded for comparison; links show
10
+ their text, and embedded content has a placeholder. Raw Markdown and repeated part labels are no
11
+ longer shown in the review.
12
+ - Proposal compilation and batch saving keep allowlisted rich text in its stored Lexical format,
13
+ separate from the model's capped Markdown projection. Unchanged nodes retain their formatting,
14
+ and previews match the content to be saved even on long documents.
15
+ - Translation batch cards use the default-language document title when the target title is empty.
16
+ The document heading stays visible while scrolling its suggestions. The draft-save hint uses
17
+ future wording so a suggestion is not mistaken for saved content.
18
+
3
19
  ## 0.11.3
4
20
 
5
21
  - Clicking "Fix N with the assistant" again after closing the panel opens it on the batch that is
@@ -8,6 +8,7 @@ import { ENDPOINT_PREFIX } from "../package-name.js";
8
8
  import { assistantRequest } from "./assistant-api.js";
9
9
  import { cardAfterApply, cardAfterPropose, changedEdits, initialCards, savePlan, waitingKeys, } from "./batch-run.js";
10
10
  import { BATCH_COPY, uiLanguage } from "./copy.js";
11
+ import { ProposalReview } from "./ProposalReview.js";
11
12
  /**
12
13
  * Many documents at once, from a work list link: the assistant suggests a change for each, one
13
14
  * request after the other, and the editor saves the ones they want. Nothing is written before that.
@@ -135,12 +136,12 @@ export function BatchPanel({ batch, onClose }) {
135
136
  const selected = cards.filter((card) => card.status === "proposed" && card.checked).length;
136
137
  const waiting = waitingKeys(cards).length;
137
138
  const savedCount = cards.filter((card) => card.status === "saved" || card.status === "draft").length;
138
- return (_jsxs("div", { className: "ea-batch", children: [_jsxs("div", { className: "ea-batch__head", children: [_jsx("h3", { children: copy.title(batch.targets.length) }), _jsxs("p", { className: "ea-batch__task", children: [_jsxs("span", { children: [copy.task, ":"] }), " ", batch.prompt] }), phase === "ready" ? (_jsxs(_Fragment, { children: [_jsx("p", { children: copy.intro(waiting) }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: waiting === 0, margin: false, onClick: () => void run(), size: "medium", children: copy.start }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: onClose, size: "medium", children: copy.cancel })] })] })) : null, phase === "running" || phase === "saving" ? (_jsx("p", { "aria-live": "polite", children: phase === "saving" ? copy.saving : copy.progress(finished, total) })) : null, phase === "review" && selected > 0 ? _jsx("p", { children: copy.review }) : null, phase === "done" ? (_jsx("p", { "aria-live": "polite", children: copy.summary(savedCount, total - savedCount) })) : null] }), _jsx("ul", { className: "ea-batch__cards", children: cards.map((card) => (_jsx(BatchCardItem, { adminRoute: config.routes.admin, card: card, copy: copy, editable: phase === "review" || phase === "done", onCheck: (checked) => update(card.key, (current) => ({ ...current, checked })), onEdit: (path, value) => update(card.key, (current) => ({
139
+ return (_jsxs("div", { className: "ea-batch", children: [_jsxs("div", { className: "ea-batch__head", children: [_jsx("h3", { children: copy.title(batch.targets.length) }), _jsxs("p", { className: "ea-batch__task", children: [_jsxs("span", { children: [copy.task, ":"] }), " ", batch.prompt] }), phase === "ready" ? (_jsxs(_Fragment, { children: [_jsx("p", { children: copy.intro(waiting) }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: waiting === 0, margin: false, onClick: () => void run(), size: "medium", children: copy.start }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: onClose, size: "medium", children: copy.cancel })] })] })) : null, phase === "running" || phase === "saving" ? (_jsx("p", { "aria-live": "polite", children: phase === "saving" ? copy.saving : copy.progress(finished, total) })) : null, phase === "review" && selected > 0 ? _jsx("p", { children: copy.review }) : null, phase === "done" ? (_jsx("p", { "aria-live": "polite", children: copy.summary(savedCount, total - savedCount) })) : null] }), _jsx("ul", { className: "ea-batch__cards", children: cards.map((card) => (_jsx(BatchCardItem, { adminRoute: config.routes.admin, card: card, copy: copy, language: language, editable: phase === "review" || phase === "done", onCheck: (checked) => update(card.key, (current) => ({ ...current, checked })), onEdit: (path, value) => update(card.key, (current) => ({
139
140
  ...current,
140
141
  edits: { ...current.edits, [path]: value },
141
142
  })), onRetry: (instruction) => void retry(card, instruction) }, card.key))) }), phase === "ready" ? null : (_jsxs("div", { className: "ea-actions ea-batch__actions", children: [phase === "running" ? (_jsx(Button, { buttonStyle: "secondary", margin: false, onClick: stop, size: "medium", children: copy.stop })) : null, phase === "review" ? (_jsxs(_Fragment, { children: [_jsx(Button, { buttonStyle: "primary", disabled: selected === 0, margin: false, onClick: () => void save(), size: "medium", children: copy.save(selected) }), waiting > 0 ? (_jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void run(), size: "medium", children: copy.continue })) : null, _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => void close(), size: "medium", children: copy.cancel })] })) : null, phase === "done" ? (_jsx(Button, { buttonStyle: "primary", margin: false, onClick: onClose, size: "medium", children: copy.close })) : null] }))] }));
142
143
  }
143
- function BatchCardItem({ adminRoute, card, copy, editable, onCheck, onEdit, onRetry, }) {
144
+ function BatchCardItem({ adminRoute, card, copy, language, editable, onCheck, onEdit, onRetry, }) {
144
145
  const [retrying, setRetrying] = useState(false);
145
146
  const [instruction, setInstruction] = useState("");
146
147
  const { target } = card;
@@ -152,7 +153,7 @@ function BatchCardItem({ adminRoute, card, copy, editable, onCheck, onEdit, onRe
152
153
  return (_jsxs("li", { className: `ea-batch__card ea-batch__card--${card.status}`, children: [_jsxs("div", { className: "ea-batch__card-head", children: [proposed ? (_jsx("input", { "aria-label": copy.select(card.title), checked: card.checked, disabled: !editable, id: checkboxId, onChange: (event) => onCheck(event.target.checked), type: "checkbox" })) : null, card.thumbnail ? (
153
154
  // A plugin cannot set up next/image for the project's media addresses.
154
155
  // biome-ignore lint/performance/noImgElement: a small thumbnail as Payload serves it
155
- _jsx("img", { alt: "", className: "ea-batch__thumb", src: card.thumbnail })) : null, _jsxs("label", { className: "ea-batch__title", htmlFor: proposed ? checkboxId : undefined, children: [card.title, target.locale ? _jsx("span", { className: "ea-batch__pill", children: target.locale }) : null] }), _jsx("span", { className: "ea-batch__status", children: copy.status[card.status] })] }), card.proposal ? (_jsx("ul", { className: "ea-batch__lines", children: card.proposal.lines.map((line, index) => line.edit ? (_jsxs("li", { className: "ea-batch__edit", children: [_jsx("span", { className: "ea-batch__label", children: line.edit.label }), _jsxs("span", { className: "ea-batch__before", children: [copy.before, ": ", line.edit.before || copy.empty] }), _jsx("textarea", { "aria-label": line.edit.label, disabled: !editable || !proposed, onChange: (event) => onEdit(line.path, event.target.value), rows: 2, value: card.edits[line.path] ?? line.edit.value }), _jsx("span", { className: "ea-batch__count", children: [...(card.edits[line.path] ?? line.edit.value)].length })] }, line.path)) : (_jsx("li", { children: line.text }, `${line.path}:${index}`))) })) : null, card.text && (card.status === "nothing" || card.status === "failed") ? (_jsx("p", { className: "ea-batch__text", children: card.text })) : null, proposed ? (_jsx("p", { className: "ea-batch__hint", children: card.drafts ? copy.savesDraft : copy.savesDirectly })) : null, card.status === "stale" ? _jsx("p", { className: "ea-batch__hint", children: copy.staleHint }) : null, retrying ? (_jsxs("form", { className: "ea-batch__retry", onSubmit: (event) => {
156
+ _jsx("img", { alt: "", className: "ea-batch__thumb", src: card.thumbnail })) : null, _jsxs("label", { className: "ea-batch__title", htmlFor: proposed ? checkboxId : undefined, children: [card.title, target.locale ? _jsx("span", { className: "ea-batch__pill", children: target.locale }) : null] }), _jsx("span", { className: "ea-batch__status", children: copy.status[card.status] })] }), card.proposal ? (_jsx(ProposalReview, { lines: card.proposal.lines, language: language, editable: editable && proposed, edits: card.edits, onEdit: onEdit })) : null, card.text && (card.status === "nothing" || card.status === "failed") ? (_jsx("p", { className: "ea-batch__text", children: card.text })) : null, proposed ? (_jsx("p", { className: "ea-batch__hint", children: card.drafts ? copy.savesDraft : copy.savesDirectly })) : null, card.status === "stale" ? _jsx("p", { className: "ea-batch__hint", children: copy.staleHint }) : null, retrying ? (_jsxs("form", { className: "ea-batch__retry", onSubmit: (event) => {
156
157
  event.preventDefault();
157
158
  setRetrying(false);
158
159
  onRetry(instruction);
@@ -10,6 +10,7 @@ import { useAssistantState, } from "./assistant-context.js";
10
10
  import { BatchPanel } from "./BatchPanel.js";
11
11
  import { releaseTakenBatches } from "./batch-link.js";
12
12
  import { COPY, translateAction, uiLanguage } from "./copy.js";
13
+ import { ProposalReview } from "./ProposalReview.js";
13
14
  import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
14
15
  import { ReplyText } from "./ReplyText.js";
15
16
  import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
@@ -449,11 +450,11 @@ export function EditorAssistantAside() {
449
450
  setBatch(null);
450
451
  } }, batch.id) })) : (_jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.role === "assistant" ? _jsx(ReplyText, { text: message.text }) : message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
451
452
  router.push(candidate.href);
452
- }, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: step ? talk.steps[step] : talk.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsxs("p", { className: "ea-approval__hint", children: [autosave ? copy.previewHintAutosave : copy.previewHint, pendingApproval.earlier?.length ? ` ${copy.refinedHint}` : ""] })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
453
+ }, size: "small", children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsxs("span", { className: "ea-dots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }), _jsx("span", { children: step ? talk.steps[step] : talk.working })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsxs("p", { className: "ea-approval__hint", children: [autosave ? copy.previewHintAutosave : copy.previewHint, pendingApproval.earlier?.length ? ` ${copy.refinedHint}` : ""] })) : null, pendingApproval.lines?.length ? (_jsx("div", { className: "ea-approval__review", children: _jsx(ProposalReview, { lines: pendingApproval.lines, language: uiLanguage(i18n.language) }) })) : (_jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
453
454
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
454
455
  _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
455
456
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
456
- _jsx("li", { children: line }, index))) }), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 || translate ? (_jsxs("div", { className: "ea-shortcuts", children: [translate ? (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
457
+ _jsx("li", { children: line }, index))) })), confirmUndo ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "ea-approval__warn", children: copy.undoLosesEdits }), _jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", margin: false, onClick: () => void undoPreview(pendingApproval, true), size: "medium", children: copy.undoAnyway }), _jsx(Button, { buttonStyle: "secondary", margin: false, onClick: () => setConfirmUndo(false), size: "medium", children: copy.cancel })] })] })) : (_jsxs("div", { className: "ea-actions", children: [_jsx(Button, { buttonStyle: "primary", disabled: status === "streaming", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", disabled: status === "streaming", margin: false, onClick: () => void decide("reject"), size: "medium", children: pendingApproval.applied ? copy.undo : copy.reject })] }))] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: statusText(status, talk) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 || translate ? (_jsxs("div", { className: "ea-shortcuts", children: [translate ? (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
457
458
  void send(translate.prompt);
458
459
  }, size: "small", children: translate.label })) : null, visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
459
460
  void send(shortcut.prompt);
@@ -0,0 +1,11 @@
1
+ import type { ProposalLine } from "../domain/propose.ts";
2
+ type Props = {
3
+ lines: ProposalLine[];
4
+ language: "en" | "sv";
5
+ editable?: boolean;
6
+ edits?: Record<string, string>;
7
+ onEdit?: (path: string, value: string) => void;
8
+ };
9
+ /** One review layout for batch proposals and changes in the open form. */
10
+ export declare function ProposalReview(props: Props): import("react").JSX.Element;
11
+ export {};
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useId } from "react";
4
+ import { groupProposalLines } from "./proposal-review.js";
5
+ import { hasRichText, RichTextPreview } from "./RichTextPreview.js";
6
+ const COPY = {
7
+ en: {
8
+ current: "Current text",
9
+ suggested: "Suggested text",
10
+ added: "New",
11
+ empty: "Cleared",
12
+ block: "Block",
13
+ },
14
+ sv: {
15
+ current: "Nuvarande text",
16
+ suggested: "Föreslagen text",
17
+ added: "Nytt",
18
+ empty: "Tömt",
19
+ block: "Block",
20
+ },
21
+ };
22
+ /** One review layout for batch proposals and changes in the open form. */
23
+ export function ProposalReview(props) {
24
+ return (_jsx("div", { className: "ea-review", children: groupProposalLines(props.lines).map((node) => (_jsx(ReviewNodeItem, { node: node, props: props }, node.path))) }));
25
+ }
26
+ function ReviewNodeItem({ node, props }) {
27
+ if (node.kind === "field")
28
+ return _jsx(FieldReview, { lines: node.lines, props: props });
29
+ const { group } = node;
30
+ const children = node.children.map((child) => (_jsx(ReviewNodeItem, { node: child, props: props }, child.path)));
31
+ if (group.kind === "container")
32
+ return (_jsxs("section", { className: "ea-review__collection", children: [_jsx("h4", { children: group.label }), _jsx("div", { className: "ea-review__children", children: children })] }));
33
+ return (_jsxs("details", { className: `ea-review__group ea-review__group--${group.kind}`, open: true, children: [_jsxs("summary", { children: [_jsx("span", { children: group.label }), group.kind === "block" ? (_jsxs("span", { className: "ea-review__badge", children: [COPY[props.language].block, " ", group.position] })) : null] }), _jsx("div", { className: "ea-review__children", children: children })] }));
34
+ }
35
+ function FieldReview({ lines, props }) {
36
+ const id = useId();
37
+ const line = lines[0];
38
+ if (!line)
39
+ return null;
40
+ const copy = COPY[props.language];
41
+ const label = line.label ?? line.edit?.label;
42
+ const rich = line.richText;
43
+ const added = rich ? !hasRichText(rich.before) : line.edit ? !line.edit.before : false;
44
+ const value = props.edits?.[line.path] ?? line.edit?.value ?? "";
45
+ return (_jsxs("div", { className: "ea-review__field", children: [label ? (_jsxs("div", { className: "ea-review__field-head", children: [_jsx("label", { htmlFor: line.edit && props.onEdit ? id : undefined, children: label }), added ? _jsx("span", { className: "ea-review__new", children: copy.added }) : null] })) : null, rich ? (_jsxs(_Fragment, { children: [hasRichText(rich.before) ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx(RichTextPreview, { value: rich.before, language: props.language })] })) : null, _jsx("div", { className: "ea-review__preview", role: "region", "aria-label": `${label}: ${copy.suggested}`, children: hasRichText(rich.after) ? (_jsx(RichTextPreview, { value: rich.after, language: props.language })) : (_jsx("span", { children: copy.empty })) })] })) : line.edit ? (_jsxs(_Fragment, { children: [line.edit.before ? (_jsxs("details", { className: "ea-review__current", children: [_jsx("summary", { children: copy.current }), _jsx("p", { children: line.edit.before })] })) : null, props.onEdit ? (_jsx("textarea", { id: id, "aria-label": [line.context, label].filter(Boolean).join(" › "), disabled: !props.editable, onChange: (event) => props.onEdit?.(line.path, event.target.value), rows: 2, value: value })) : (_jsx("p", { className: "ea-review__value", children: value || copy.empty })), props.onEdit ? _jsx("span", { className: "ea-review__count", children: [...value].length }) : null] })) : (_jsx("div", { className: "ea-review__change", children: lines.map((line, index) => (_jsx("p", { children: line.text }, `${line.path}:${index}`))) }))] }));
46
+ }
@@ -0,0 +1,6 @@
1
+ export declare function hasRichText(value: unknown): boolean;
2
+ /** Render stored Lexical content as safe React elements; no HTML or live links from content. */
3
+ export declare function RichTextPreview({ value, language }: {
4
+ value: unknown;
5
+ language: "en" | "sv";
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,76 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createElement, Fragment } from "react";
4
+ export function hasRichText(value) {
5
+ const visit = (value) => {
6
+ if (!value || typeof value !== "object")
7
+ return false;
8
+ const node = value;
9
+ if (node.root)
10
+ return visit(node.root);
11
+ if (typeof node.text === "string" && node.text.trim())
12
+ return true;
13
+ if (Array.isArray(node.children) && node.children.some(visit))
14
+ return true;
15
+ return Boolean(node.type &&
16
+ !["root", "paragraph", "heading", "text", "linebreak", "list", "listitem"].includes(node.type));
17
+ };
18
+ return typeof value === "string" ? Boolean(value.trim()) : visit(value);
19
+ }
20
+ /** Render stored Lexical content as safe React elements; no HTML or live links from content. */
21
+ export function RichTextPreview({ value, language }) {
22
+ const root = value?.root;
23
+ const embedded = language === "sv" ? "Inbäddat innehåll" : "Embedded content";
24
+ const render = (value, key) => {
25
+ if (!value || typeof value !== "object")
26
+ return null;
27
+ const node = value;
28
+ const children = Array.isArray(node.children) ? node.children.map(render) : null;
29
+ const style = typeof node.format === "string" &&
30
+ ["left", "right", "center", "justify"].includes(node.format)
31
+ ? { textAlign: node.format }
32
+ : undefined;
33
+ if (node.type === "text") {
34
+ let text = typeof node.text === "string" ? node.text : "";
35
+ const format = typeof node.format === "number" ? node.format : 0;
36
+ // Lexical's serialized TextNode format flags, independent of the optional editor peer.
37
+ if (format & 1)
38
+ text = _jsx("strong", { children: text });
39
+ if (format & 2)
40
+ text = _jsx("em", { children: text });
41
+ if (format & 4)
42
+ text = _jsx("s", { children: text });
43
+ if (format & 8)
44
+ text = _jsx("u", { children: text });
45
+ if (format & 16)
46
+ text = _jsx("code", { children: text });
47
+ if (format & 32)
48
+ text = _jsx("sub", { children: text });
49
+ if (format & 64)
50
+ text = _jsx("sup", { children: text });
51
+ return _jsx(Fragment, { children: text }, key);
52
+ }
53
+ switch (node.type) {
54
+ case "paragraph":
55
+ return (_jsx("p", { style: style, children: children }, key));
56
+ case "heading":
57
+ return createElement(/^h[1-6]$/.test(node.tag ?? "") ? node.tag : "h3", { key, style }, children);
58
+ case "list":
59
+ return node.tag === "ol" ? (_jsx("ol", { start: node.start, children: children }, key)) : (_jsx("ul", { children: children }, key));
60
+ case "listitem":
61
+ return _jsx("li", { children: children }, key);
62
+ case "quote":
63
+ return (_jsx("blockquote", { style: style, children: children }, key));
64
+ case "horizontalrule":
65
+ return _jsx("hr", {}, key);
66
+ case "linebreak":
67
+ return _jsx("br", {}, key);
68
+ case "link":
69
+ case "autolink":
70
+ return (_jsx("span", { className: "ea-review__link", title: node.fields?.url ?? (language === "sv" ? "Intern länk" : "Internal link"), children: children }, key));
71
+ default:
72
+ return (_jsx("span", { className: "ea-review__embedded", children: embedded }, key));
73
+ }
74
+ };
75
+ return (_jsx("div", { className: "ea-review__rich", children: Array.isArray(root?.children) ? (root.children.map(render)) : typeof value === "string" ? (_jsx("p", { children: value })) : null }));
76
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { BatchScope, BatchTarget } from "../batch/targets.ts";
3
+ import type { ProposalLine } from "../domain/propose.ts";
3
4
  import type { AssistantShortcut } from "../types.ts";
4
5
  import type { DomSelection } from "./selection-dom.ts";
5
6
  export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "no_model" | "tool_denied" | "stale_context" | "aborted" | "streaming";
@@ -23,6 +24,7 @@ export type FormAdapter = {
23
24
  export type PendingApproval = {
24
25
  title: string;
25
26
  summary: string;
27
+ lines?: ProposalLine[];
26
28
  approvalId: string;
27
29
  nonce: string;
28
30
  capability: "form.propose" | "draft.create";
@@ -156,7 +156,7 @@ export declare const BATCH_COPY: {
156
156
  readonly save: (count: number) => string;
157
157
  readonly saving: "Saving…";
158
158
  readonly review: "Check the suggestions. Uncheck any you do not want, or change the wording before saving.";
159
- readonly savesDraft: "Saved as a draft; nothing is published.";
159
+ readonly savesDraft: "Saves as a draft; nothing is published.";
160
160
  readonly savesDirectly: "Saved directly: this collection has no drafts.";
161
161
  readonly before: "Before";
162
162
  readonly empty: "empty";
@@ -166,7 +166,7 @@ export const BATCH_COPY = {
166
166
  save: (count) => `Save ${count} selected`,
167
167
  saving: "Saving…",
168
168
  review: "Check the suggestions. Uncheck any you do not want, or change the wording before saving.",
169
- savesDraft: "Saved as a draft; nothing is published.",
169
+ savesDraft: "Saves as a draft; nothing is published.",
170
170
  savesDirectly: "Saved directly: this collection has no drafts.",
171
171
  before: "Before",
172
172
  empty: "empty",
@@ -0,0 +1,16 @@
1
+ import type { ProposalLine } from "../domain/propose.ts";
2
+ import type { FieldPathGroup } from "../form/field-label.ts";
3
+ export type ReviewField = {
4
+ kind: "field";
5
+ path: string;
6
+ lines: ProposalLine[];
7
+ };
8
+ export type ReviewGroup = {
9
+ kind: "group";
10
+ path: string;
11
+ group: FieldPathGroup;
12
+ children: ReviewNode[];
13
+ };
14
+ export type ReviewNode = ReviewField | ReviewGroup;
15
+ /** Merge fields under their actual parents and keep blocks and rows in document order. */
16
+ export declare function groupProposalLines(lines: ProposalLine[]): ReviewNode[];
@@ -0,0 +1,34 @@
1
+ /** Merge fields under their actual parents and keep blocks and rows in document order. */
2
+ export function groupProposalLines(lines) {
3
+ const root = [];
4
+ for (const line of lines) {
5
+ let children = root;
6
+ for (const group of line.groups ?? []) {
7
+ let node = children.find((node) => node.kind === "group" && node.path === group.path);
8
+ if (!node) {
9
+ node = { kind: "group", path: group.path, group, children: [] };
10
+ children.push(node);
11
+ }
12
+ children = node.children;
13
+ }
14
+ const existing = children.find((node) => node.kind === "field" && node.path === line.path);
15
+ if (existing)
16
+ existing.lines.push(line);
17
+ else
18
+ children.push({ kind: "field", path: line.path, lines: [line] });
19
+ }
20
+ const sort = (nodes) => {
21
+ nodes.sort((a, b) => a.kind !== b.kind
22
+ ? a.kind === "field"
23
+ ? -1
24
+ : 1
25
+ : a.kind === "group"
26
+ ? a.path.localeCompare(b.path, "en", { numeric: true })
27
+ : 0);
28
+ for (const node of nodes)
29
+ if (node.kind === "group")
30
+ sort(node.children);
31
+ };
32
+ sort(root);
33
+ return root;
34
+ }
@@ -5,14 +5,14 @@ import { isFormOperation } from "../form/operations.js";
5
5
  import { cloneData, getAtPath, parseFieldPath, setAtPath } from "../form/path.js";
6
6
  import { relationValueError } from "../form/relation-shape.js";
7
7
  import { loadRichText } from "../form/rich-text.js";
8
- import { finalizeCompiled, openRichText } from "../form/shape.js";
8
+ import { finalizeCompiled, openRichText, richTextPaths } from "../form/shape.js";
9
9
  import { translationPaths } from "../form/translation.js";
10
10
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
11
11
  import { runAgentTurn } from "../runtime/agent.js";
12
12
  import { sourceLocale } from "../runtime/translation-source.js";
13
13
  import { configuredDefaultLocale, configuredLocales, entityFields, findCollectionConfig, hasDrafts, } from "../schema/discover.js";
14
14
  import { fieldAtPath } from "../schema/fields.js";
15
- import { projectRecord } from "../schema/project.js";
15
+ import { projectFormRecord, projectRecord } from "../schema/project.js";
16
16
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
17
17
  import { writeAudit } from "./audit.js";
18
18
  import { readDocument } from "./content.js";
@@ -110,6 +110,7 @@ async function readSaved(req, options, collection, id, locale) {
110
110
  const title = useAsTitle ? read.doc[useAsTitle] : undefined;
111
111
  return {
112
112
  data: projectRecord(read.doc, read.fields, options.redact, read.slug).data,
113
+ form: projectFormRecord(read.doc, read.fields, options.redact, read.slug),
113
114
  raw: read.doc,
114
115
  title: typeof title === "string" && title ? title : undefined,
115
116
  };
@@ -187,24 +188,24 @@ export async function applyBatchTarget(req, options, input) {
187
188
  if (!saved) {
188
189
  return fail("tool_denied", "Document is not accessible.");
189
190
  }
190
- if ((await hashFormRevision(saved.data)) !== record.formRevision) {
191
+ if ((await hashFormRevision(saved.form)) !== record.formRevision) {
191
192
  return fail("stale_context", "The document changed after the proposal.");
192
193
  }
193
194
  const fields = entityFields(req, options, { collection: record.targetCollection });
194
- const edited = withEdits(stored, input.edits, fields, saved.data);
195
+ const edited = withEdits(stored, input.edits, fields, saved.form);
195
196
  if (!edited.ok) {
196
197
  // Not the editor's approval to spend: the card can still be saved without the edit.
197
198
  return { ok: false, error: "tool_denied", message: edited.message };
198
199
  }
199
200
  const operations = edited.operations;
200
- const compiled = compileOperations(openRichText(saved.data, fields), operations, {
201
+ const compiled = compileOperations(openRichText(saved.form, fields), operations, {
201
202
  allowlist: resolved.allowlist.fields,
202
203
  denyFields: options.denyFields,
203
204
  });
204
205
  if (!compiled.ok) {
205
206
  return fail("tool_denied", compiled.error);
206
207
  }
207
- const finalized = finalizeCompiled(compiled.data, fields, saved.data);
208
+ const finalized = finalizeCompiled(compiled.data, fields, saved.form);
208
209
  if (!finalized.ok) {
209
210
  return fail("tool_denied", finalized.error);
210
211
  }
@@ -224,12 +225,15 @@ export async function applyBatchTarget(req, options, input) {
224
225
  return fail("tool_denied", relations.message);
225
226
  }
226
227
  const drafts = hasDrafts(findCollectionConfig(req, record.targetCollection)?.versions);
228
+ // The model addresses Markdown parts; Payload stores the containing Lexical field as a unit.
229
+ const richPaths = richTextPaths(operations, fields, saved.form);
230
+ const storedPaths = paths.map((path) => richPaths.find((field) => path === field || path.startsWith(`${field}.`)) ?? path);
227
231
  try {
228
232
  await localApi(req).update({
229
233
  collection: record.targetCollection,
230
234
  id: record.documentId,
231
235
  locale: record.locale,
232
- data: changedTopLevel(saved.raw, finalized.data, paths),
236
+ data: changedTopLevel(saved.raw, finalized.data, storedPaths),
233
237
  draft: drafts,
234
238
  depth: 0,
235
239
  req,
@@ -322,7 +326,18 @@ export async function describeBatchTargets(req, options, targets) {
322
326
  : undefined;
323
327
  const doc = read?.ok ? read.doc : undefined;
324
328
  const useAsTitle = config?.admin?.useAsTitle;
325
- const title = [useAsTitle ? doc?.[useAsTitle] : undefined, doc?.filename].find((value) => typeof value === "string" && value.trim() !== "");
329
+ let title = [useAsTitle ? doc?.[useAsTitle] : undefined, doc?.filename].find((value) => typeof value === "string" && value.trim() !== "");
330
+ const defaultLocale = configuredDefaultLocale(req);
331
+ if (doc && !title && defaultLocale && target.locale && defaultLocale !== target.locale) {
332
+ const source = await readDocument(req, options, {
333
+ collection: target.collection,
334
+ documentId: target.id,
335
+ locale: defaultLocale,
336
+ });
337
+ const sourceTitle = source.ok && useAsTitle ? source.doc[useAsTitle] : undefined;
338
+ if (typeof sourceTitle === "string" && sourceTitle.trim())
339
+ title = sourceTitle;
340
+ }
326
341
  infos.push({
327
342
  key: target.key,
328
343
  title: title ?? target.id,
@@ -1,5 +1,6 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import { type FieldPathGroup } from "../form/field-label.ts";
3
4
  export type ProposeInput = {
4
5
  capability: "form.propose" | "draft.create" | "content.update";
5
6
  collection?: string;
@@ -22,6 +23,15 @@ export type ProposeInput = {
22
23
  export type ProposalLine = {
23
24
  path: string;
24
25
  text: string;
26
+ /** Parent groups, blocks and row positions, separate from an editable field's label. */
27
+ context?: string;
28
+ label?: string;
29
+ groups?: FieldPathGroup[];
30
+ /** Complete stored content before and after compilation, not a truncated Markdown diff. */
31
+ richText?: {
32
+ before: unknown;
33
+ after: unknown;
34
+ };
25
35
  edit?: {
26
36
  label: string;
27
37
  before: string;
@@ -1,19 +1,20 @@
1
1
  import { changedPaths } from "../form/changed-paths.js";
2
2
  import { compileOperations } from "../form/compile.js";
3
3
  import { humanDiff, richTextLines } from "../form/diff.js";
4
+ import { fieldPathInfo } from "../form/field-label.js";
4
5
  import { hashFormRevision } from "../form/hash.js";
5
6
  import { isFormOperation, normalizeOperations } from "../form/operations.js";
6
7
  import { getAtPath, parseFieldPath } from "../form/path.js";
7
8
  import { relationValueError } from "../form/relation-shape.js";
8
9
  import { loadRichText, toBlocks } from "../form/rich-text.js";
9
- import { finalizeCompiled, openRichText } from "../form/shape.js";
10
+ import { finalizeCompiled, openRichText, richTextPaths } from "../form/shape.js";
10
11
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
11
12
  import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
12
13
  import { fieldAtPath } from "../schema/fields.js";
13
- import { projectRecord } from "../schema/project.js";
14
+ import { projectFormRecord } from "../schema/project.js";
14
15
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
15
16
  import { writeAudit } from "./audit.js";
16
- import { findContentById, readDocument } from "./content.js";
17
+ import { readDocument } from "./content.js";
17
18
  import { localApi } from "./local-api.js";
18
19
  import { labelRelations, verifyDocumentRelations } from "./relations.js";
19
20
  /** Long enough to look at a change in the preview before keeping it. */
@@ -97,14 +98,40 @@ export async function proposeChange(req, options, input) {
97
98
  : undefined;
98
99
  const inRichText = (operation) => "path" in operation &&
99
100
  richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
101
+ const fieldLabel = (path) => fieldPathInfo(fields, path, current, input.language);
100
102
  const diff = [
101
- ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels),
102
- ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language)),
103
+ ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels, (path) => fieldLabel(path).full),
104
+ ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language, (path) => fieldLabel(path).full)),
103
105
  ];
104
- const lines = diff.map((line) => ({
105
- ...line,
106
- ...editableLine(line.path, typedOperations, fields, current),
107
- }));
106
+ const lines = diff
107
+ .filter((line) => !richTextBefore.some(({ path }) => path === line.path))
108
+ .map((line) => {
109
+ const { context, label, groups } = fieldLabel(line.path);
110
+ return {
111
+ ...line,
112
+ label,
113
+ groups,
114
+ ...(context ? { context } : {}),
115
+ ...editableLine(line.path, typedOperations, fields, current),
116
+ };
117
+ });
118
+ for (const { path } of richTextBefore) {
119
+ const { context, label, groups } = fieldLabel(path);
120
+ lines.push({
121
+ path,
122
+ label,
123
+ groups,
124
+ ...(context ? { context } : {}),
125
+ text: diff
126
+ .filter((line) => line.path === path)
127
+ .map((line) => line.text)
128
+ .join("\n"),
129
+ richText: {
130
+ before: getAtPath(current, parseFieldPath(path)),
131
+ after: getAtPath(finalized.data, parseFieldPath(path)),
132
+ },
133
+ });
134
+ }
108
135
  let created;
109
136
  try {
110
137
  created = await localApi(req).create({
@@ -179,7 +206,10 @@ async function resolveCurrentForm(req, options, input) {
179
206
  if (input.capability !== "form.propose") {
180
207
  return {};
181
208
  }
182
- const saved = await findContentById(req, options, {
209
+ const resolved = resolveAllowlist(options, input);
210
+ if (!resolved || !hasCapability(resolved.allowlist, "content.findById"))
211
+ return snapshot;
212
+ const saved = await readDocument(req, options, {
183
213
  collection: input.collection,
184
214
  global: input.global,
185
215
  documentId: input.documentId,
@@ -188,7 +218,7 @@ async function resolveCurrentForm(req, options, input) {
188
218
  if (!saved.ok) {
189
219
  return snapshot;
190
220
  }
191
- return { ...saved.data, ...snapshot };
221
+ return { ...projectFormRecord(saved.doc, saved.fields, options.redact, saved.slug), ...snapshot };
192
222
  }
193
223
  /** A saved document as the batch sees it: allowlisted fields in the target's language. */
194
224
  export async function readSavedForm(req, options, target) {
@@ -203,7 +233,7 @@ export async function readSavedForm(req, options, target) {
203
233
  if (!read.ok) {
204
234
  return null;
205
235
  }
206
- return projectRecord(read.doc, read.fields, options.redact, read.slug).data;
236
+ return projectFormRecord(read.doc, read.fields, options.redact, read.slug);
207
237
  }
208
238
  /**
209
239
  * A batch proposal sets or clears values within its target's fields: no rows or blocks added,
@@ -263,23 +293,6 @@ function draftCreateDenied(req, collection) {
263
293
  }
264
294
  return null;
265
295
  }
266
- /** Rich text fields the operations reach, whole or by block, as form paths. */
267
- function richTextPaths(operations, fields, current) {
268
- const paths = new Set();
269
- for (const operation of operations) {
270
- if (!("path" in operation))
271
- continue;
272
- const segments = operation.path.split(".");
273
- for (let end = 1; end <= segments.length; end++) {
274
- const prefix = segments.slice(0, end).join(".");
275
- if (fieldAtPath(fields, prefix, current)?.type === "richText") {
276
- paths.add(prefix);
277
- break;
278
- }
279
- }
280
- }
281
- return [...paths];
282
- }
283
296
  /** The stored rich text at `path` as the model read it: Markdown blocks and `{ keep, type }`. */
284
297
  function storedBlocks(fields, path, current) {
285
298
  const field = fieldAtPath(fields, path, current);
@@ -8,11 +8,11 @@ export type DiffLanguage = "sv" | "en";
8
8
  * `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
9
9
  * filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
10
10
  */
11
- export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage, labels?: Map<string, string>): HumanDiffLine[];
11
+ export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage, labels?: Map<string, string>, fieldLabel?: (path: string) => string): HumanDiffLine[];
12
12
  /**
13
13
  * The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
14
14
  * `after` as compiled, where `{ keep: i }` is block `i` unchanged.
15
15
  */
16
- export declare function richTextLines(path: string, before: unknown[], after: unknown[], language?: DiffLanguage): HumanDiffLine[];
16
+ export declare function richTextLines(path: string, before: unknown[], after: unknown[], language?: DiffLanguage, fieldLabel?: (path: string) => string): HumanDiffLine[];
17
17
  /** Sub-lines are indented two spaces; the approval panel nests them under the header. */
18
18
  export declare const DIFF_SUBLINE_INDENT = " ";
package/dist/form/diff.js CHANGED
@@ -41,7 +41,7 @@ const FIELD_PREVIEW = 300;
41
41
  * `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
42
42
  * filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
43
43
  */
44
- export function humanDiff(current, operations, language = "en", labels) {
44
+ export function humanDiff(current, operations, language = "en", labels, fieldLabel = label) {
45
45
  const copy = COPY[language];
46
46
  const nameOf = (value) => {
47
47
  const id = typeof value === "string" || typeof value === "number"
@@ -65,30 +65,30 @@ export function humanDiff(current, operations, language = "en", labels) {
65
65
  const before = getAtPath(current, parseFieldPath(operation.path));
66
66
  return {
67
67
  path: operation.path,
68
- text: `${label(operation.path)}: ${preview(named(before), copy)} → ${preview(named(operation.value), copy)}`,
68
+ text: `${fieldLabel(operation.path)}: ${preview(named(before), copy)} → ${preview(named(operation.value), copy)}`,
69
69
  };
70
70
  }
71
71
  case "unset":
72
- return { path: operation.path, text: copy.clear(label(operation.path)) };
72
+ return { path: operation.path, text: copy.clear(fieldLabel(operation.path)) };
73
73
  case "array.insert":
74
74
  return {
75
75
  path: operation.path,
76
- text: withFields(copy.addItem(operation.index + 1, label(operation.path)), operation.value, copy),
76
+ text: withFields(copy.addItem(operation.index + 1, fieldLabel(operation.path)), operation.value, copy),
77
77
  };
78
78
  case "array.remove":
79
79
  return {
80
80
  path: operation.path,
81
- text: copy.removeItem(operation.index + 1, label(operation.path)),
81
+ text: copy.removeItem(operation.index + 1, fieldLabel(operation.path)),
82
82
  };
83
83
  case "array.move":
84
84
  return {
85
85
  path: operation.path,
86
- text: copy.moveItem(operation.from + 1, operation.to + 1, label(operation.path)),
86
+ text: copy.moveItem(operation.from + 1, operation.to + 1, fieldLabel(operation.path)),
87
87
  };
88
88
  case "array.replace":
89
89
  return {
90
90
  path: operation.path,
91
- text: withFields(copy.replaceItem(operation.index + 1, label(operation.path)), operation.value, copy),
91
+ text: withFields(copy.replaceItem(operation.index + 1, fieldLabel(operation.path)), operation.value, copy),
92
92
  };
93
93
  case "blocks.insert":
94
94
  return {
@@ -98,7 +98,7 @@ export function humanDiff(current, operations, language = "en", labels) {
98
98
  case "blocks.remove":
99
99
  return {
100
100
  path: operation.path,
101
- text: copy.removeBlock(operation.index + 1, label(operation.path)),
101
+ text: copy.removeBlock(operation.index + 1, fieldLabel(operation.path)),
102
102
  };
103
103
  case "blocks.move":
104
104
  return { path: operation.path, text: copy.moveBlock(operation.from + 1, operation.to + 1) };
@@ -116,9 +116,9 @@ export function humanDiff(current, operations, language = "en", labels) {
116
116
  * The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
117
117
  * `after` as compiled, where `{ keep: i }` is block `i` unchanged.
118
118
  */
119
- export function richTextLines(path, before, after, language = "en") {
119
+ export function richTextLines(path, before, after, language = "en", fieldLabel = label) {
120
120
  const copy = COPY[language];
121
- const field = label(path);
121
+ const field = fieldLabel(path);
122
122
  const part = (block) => typeof block === "string"
123
123
  ? preview(block, copy, FIELD_PREVIEW)
124
124
  : `[${block.type ?? "?"}]`;
@@ -0,0 +1,20 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ export type FieldPathGroup = {
3
+ path: string;
4
+ label: string;
5
+ kind: "container" | "block" | "row" | "group";
6
+ position?: number;
7
+ };
8
+ /** Schema labels and one-based row positions for a concrete form field path. */
9
+ export declare function fieldPathLabels(fields: FieldNode[], path: string, data: unknown, language?: "en" | "sv"): {
10
+ label: string;
11
+ context?: string;
12
+ full: string;
13
+ };
14
+ /** Structured parents let the review group fields instead of repeating breadcrumbs. */
15
+ export declare function fieldPathInfo(fields: FieldNode[], path: string, data: unknown, language?: "en" | "sv"): {
16
+ label: string;
17
+ context?: string;
18
+ full: string;
19
+ groups: FieldPathGroup[];
20
+ };
@@ -0,0 +1,72 @@
1
+ /** Schema labels and one-based row positions for a concrete form field path. */
2
+ export function fieldPathLabels(fields, path, data, language = "en") {
3
+ const { groups: _groups, ...labels } = fieldPathInfo(fields, path, data, language);
4
+ return labels;
5
+ }
6
+ /** Structured parents let the review group fields instead of repeating breadcrumbs. */
7
+ export function fieldPathInfo(fields, path, data, language = "en") {
8
+ const parts = [];
9
+ const groups = [];
10
+ let scope = fields;
11
+ let value = data;
12
+ let field;
13
+ let schemaPath = "";
14
+ let concretePath = "";
15
+ for (const segment of path.split(".")) {
16
+ concretePath = concretePath ? `${concretePath}.${segment}` : segment;
17
+ value = value?.[segment];
18
+ if (/^\d+$/.test(segment)) {
19
+ const position = Number(segment) + 1;
20
+ if (field?.type === "blocks") {
21
+ const blockType = value?.blockType;
22
+ const block = field.blocks?.find((block) => block.slug === blockType);
23
+ parts.push(`${name(block?.label ?? block?.slug ?? "block")} (block ${position})`);
24
+ groups.push({
25
+ path: concretePath,
26
+ label: name(block?.label ?? block?.slug ?? "block"),
27
+ kind: "block",
28
+ position,
29
+ });
30
+ scope = block?.fields ?? [];
31
+ }
32
+ else {
33
+ const row = language === "sv" ? "rad" : "row";
34
+ parts[parts.length - 1] = `${parts.at(-1) ?? ""} (${row} ${position})`;
35
+ groups.push({
36
+ path: concretePath,
37
+ label: `${name(row)} ${position}`,
38
+ kind: "row",
39
+ position,
40
+ });
41
+ }
42
+ continue;
43
+ }
44
+ schemaPath = schemaPath ? `${schemaPath}.${segment}` : segment;
45
+ field = scope.find((entry) => entry.path === schemaPath);
46
+ parts.push(name(field?.label ?? segment));
47
+ if (field?.type === "group" || field?.type === "blocks" || field?.type === "array") {
48
+ groups.push({
49
+ path: concretePath,
50
+ label: name(field.label ?? segment),
51
+ kind: field.type === "group" ? "group" : "container",
52
+ });
53
+ }
54
+ else if (!field && scope.some((entry) => entry.path.startsWith(`${schemaPath}.`))) {
55
+ groups.push({ path: concretePath, label: name(segment), kind: "group" });
56
+ }
57
+ // Named tabs are flattened by walkFields, but their name remains in each child's path.
58
+ if (field)
59
+ scope = field.fields ?? [];
60
+ }
61
+ const label = parts.at(-1) ?? name(path);
62
+ const context = parts.length > 1 ? parts.slice(0, -1).join(" › ") : undefined;
63
+ return {
64
+ label,
65
+ ...(context ? { context } : {}),
66
+ full: parts.join(" › ") || label,
67
+ groups: groups.filter((group) => group.path !== path),
68
+ };
69
+ }
70
+ function name(value) {
71
+ return value.charAt(0).toUpperCase() + value.slice(1);
72
+ }
@@ -1,5 +1,8 @@
1
- import type { FieldNode } from "../schema/fields.ts";
1
+ import { type FieldNode } from "../schema/fields.ts";
2
+ import type { FormOperation } from "./operations.ts";
2
3
  import { type RichTextLink } from "./rich-text.ts";
4
+ /** Rich text fields the operations reach, whole or by block, as form paths. */
5
+ export declare function richTextPaths(operations: FormOperation[], fields: FieldNode[], current: Record<string, unknown>): string[];
3
6
  /**
4
7
  * A copy of `data` where each rich text field the converters can read is a list of `{ keep: i }`,
5
8
  * one per stored block, so operations can set, insert or remove single blocks (`layout.1.content.2`)
@@ -1,4 +1,22 @@
1
+ import { fieldAtPath } from "../schema/fields.js";
1
2
  import { fromBlocks, isLexicalState, isRichTextBlocks, toBlocks, } from "./rich-text.js";
3
+ /** Rich text fields the operations reach, whole or by block, as form paths. */
4
+ export function richTextPaths(operations, fields, current) {
5
+ const paths = new Set();
6
+ for (const operation of operations) {
7
+ if (!("path" in operation))
8
+ continue;
9
+ const segments = operation.path.split(".");
10
+ for (let end = 1; end <= segments.length; end++) {
11
+ const prefix = segments.slice(0, end).join(".");
12
+ if (fieldAtPath(fields, prefix, current)?.type === "richText") {
13
+ paths.add(prefix);
14
+ break;
15
+ }
16
+ }
17
+ }
18
+ return [...paths];
19
+ }
2
20
  /**
3
21
  * A copy of `data` where each rich text field the converters can read is a list of `{ keep: i }`,
4
22
  * one per stored block, so operations can set, insert or remove single blocks (`layout.1.content.2`)
@@ -13,7 +13,7 @@ export type AgentChatResult = {
13
13
  capability: "form.propose" | "draft.create" | "content.update";
14
14
  /** The form with the change applied, shown in Admin before the editor keeps it. */
15
15
  preview?: ProposalPreview;
16
- /** A batch's change list, with what the editor may rewrite. */
16
+ /** Structured fields and rich text previews for Admin's review. */
17
17
  lines?: ProposalLine[];
18
18
  };
19
19
  navigate?: {
@@ -228,7 +228,10 @@ function buildSdkTools(ai, ctx, captured, previews, emit) {
228
228
  : { ...executed, preview: undefined };
229
229
  if (preview && executed.ok && executed.pendingApproval) {
230
230
  previews.set(executed.pendingApproval.approvalId, preview);
231
- emit({ type: "proposal", pendingApproval: { ...executed.pendingApproval, preview } });
231
+ emit({
232
+ type: "proposal",
233
+ pendingApproval: { ...executed.pendingApproval, lines: executed.lines, preview },
234
+ });
232
235
  }
233
236
  emit({
234
237
  type: "step",
@@ -53,7 +53,7 @@ export type ToolSuccess = {
53
53
  nonce: string;
54
54
  capability: "form.propose" | "draft.create" | "content.update";
55
55
  };
56
- /** The change list; a batch shows it per document. */
56
+ /** Structured review data for Admin, including full rich text previews. */
57
57
  lines?: ProposalLine[];
58
58
  navigate?: {
59
59
  href: string;
@@ -94,7 +94,36 @@ export declare function executeTool(name: string, input: Record<string, unknown>
94
94
  */
95
95
  export declare function toolModelOutput(outcome: ToolOutcome): {
96
96
  type: "json";
97
- value: ToolOutcome;
97
+ value: ToolFailure;
98
+ } | {
99
+ type: "json";
100
+ value: {
101
+ ok: true;
102
+ capability: string;
103
+ data?: unknown;
104
+ pendingApproval?: {
105
+ title: string;
106
+ summary: string;
107
+ approvalId: string;
108
+ nonce: string;
109
+ capability: "form.propose" | "draft.create" | "content.update";
110
+ };
111
+ navigate?: {
112
+ href: string;
113
+ label: string;
114
+ } | {
115
+ candidates: Array<{
116
+ href: string;
117
+ label: string;
118
+ }>;
119
+ };
120
+ /** For the model only, as file parts (see `toolModelOutput`); never sent to Admin. */
121
+ images?: Array<{
122
+ id?: string | number;
123
+ mediaType: string;
124
+ data: string;
125
+ }>;
126
+ };
98
127
  } | {
99
128
  type: "content";
100
129
  value: ({
@@ -233,7 +233,7 @@ export async function executeTool(name, input, ctx) {
233
233
  nonce: proposed.nonce,
234
234
  capability,
235
235
  },
236
- ...(batch ? { lines: proposed.lines } : {}),
236
+ lines: proposed.lines,
237
237
  preview: proposed.preview,
238
238
  };
239
239
  }
@@ -246,10 +246,15 @@ export async function executeTool(name, input, ctx) {
246
246
  * has one, and the rest as text before them.
247
247
  */
248
248
  export function toolModelOutput(outcome) {
249
- if (!outcome.ok || !outcome.images?.length) {
249
+ if (!outcome.ok) {
250
250
  return { type: "json", value: outcome };
251
251
  }
252
- const { images, ...rest } = outcome;
252
+ // Full previews belong to Admin; the model already has its operations and the text summary.
253
+ const { lines: _lines, preview: _preview, ...modelOutcome } = outcome;
254
+ if (!modelOutcome.images?.length) {
255
+ return { type: "json", value: modelOutcome };
256
+ }
257
+ const { images, ...rest } = modelOutcome;
253
258
  return {
254
259
  type: "content",
255
260
  value: [
@@ -3,10 +3,15 @@ import { type FieldNode } from "./fields.ts";
3
3
  export declare function projectRecord(data: Record<string, unknown> | null | undefined, fields: FieldNode[], redact?: (input: {
4
4
  slug: string;
5
5
  data: Record<string, unknown>;
6
- }) => Record<string, unknown>, slug?: string): {
6
+ }) => Record<string, unknown>, slug?: string, mode?: "model" | "form"): {
7
7
  data: Record<string, unknown>;
8
8
  truncated: boolean;
9
9
  };
10
+ /** Allowlisted, redacted form data keeps stored rich text and is never replaced by a size excerpt. */
11
+ export declare function projectFormRecord(data: Record<string, unknown>, fields: FieldNode[], redact?: (input: {
12
+ slug: string;
13
+ data: Record<string, unknown>;
14
+ }) => Record<string, unknown>, slug?: string): Record<string, unknown>;
10
15
  export declare function projectIdentity(doc: Record<string, unknown>, locale?: string): Record<string, unknown>;
11
16
  export declare function capRecord(data: Record<string, unknown>): {
12
17
  data: Record<string, unknown>;
@@ -2,7 +2,7 @@ import { isDeniedFieldName } from "../config/denied-fields.js";
2
2
  import { toBlocks } from "../form/rich-text.js";
3
3
  import { isFieldAllowed } from "./fields.js";
4
4
  const MAX_CHARS = 16_000;
5
- export function projectRecord(data, fields, redact, slug = "") {
5
+ export function projectRecord(data, fields, redact, slug = "", mode = "model") {
6
6
  const projected = {};
7
7
  if (!data) {
8
8
  return { data: projected, truncated: false };
@@ -11,10 +11,14 @@ export function projectRecord(data, fields, redact, slug = "") {
11
11
  if (!(field.name in data)) {
12
12
  continue;
13
13
  }
14
- projected[field.name] = projectValue(data[field.name], field);
14
+ projected[field.name] = projectValue(data[field.name], field, mode);
15
15
  }
16
16
  const redacted = redact ? redact({ slug, data: projected }) : projected;
17
- return capRecord(redacted);
17
+ return mode === "form" ? { data: redacted, truncated: false } : capRecord(redacted);
18
+ }
19
+ /** Allowlisted, redacted form data keeps stored rich text and is never replaced by a size excerpt. */
20
+ export function projectFormRecord(data, fields, redact, slug = "") {
21
+ return projectRecord(data, fields, redact, slug, "form").data;
18
22
  }
19
23
  export function projectIdentity(doc, locale) {
20
24
  const identity = {
@@ -53,13 +57,14 @@ export function filterRootKeys(data, allowlist, denyFields) {
53
57
  }
54
58
  return next;
55
59
  }
56
- function projectValue(value, field) {
60
+ function projectValue(value, field, mode) {
57
61
  if (value === null || value === undefined) {
58
62
  return value;
59
63
  }
60
64
  if (field.type === "array" && Array.isArray(value)) {
61
65
  return value.map((item) => item && typeof item === "object" && !Array.isArray(item)
62
- ? projectRecord(item, field.fields ?? []).data
66
+ ? projectRecord(item, field.fields ?? [], undefined, "", mode)
67
+ .data
63
68
  : item);
64
69
  }
65
70
  if (field.type === "blocks" && Array.isArray(value)) {
@@ -70,7 +75,7 @@ function projectValue(value, field) {
70
75
  const record = item;
71
76
  const blockType = typeof record.blockType === "string" ? record.blockType : "";
72
77
  const block = field.blocks?.find((entry) => entry.slug === blockType);
73
- const projected = projectRecord(record, block?.fields ?? []).data;
78
+ const projected = projectRecord(record, block?.fields ?? [], undefined, "", mode).data;
74
79
  return {
75
80
  id: record.id,
76
81
  blockType,
@@ -79,13 +84,14 @@ function projectValue(value, field) {
79
84
  });
80
85
  }
81
86
  if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
82
- return projectRecord(value, field.fields ?? []).data;
87
+ return projectRecord(value, field.fields ?? [], undefined, "", mode)
88
+ .data;
83
89
  }
84
90
  if (field.type === "relationship" || field.type === "upload") {
85
91
  return projectRelation(value);
86
92
  }
87
93
  if (field.type === "richText") {
88
- return toBlocks(value, field.editor) ?? value;
94
+ return mode === "form" ? value : (toBlocks(value, field.editor) ?? value);
89
95
  }
90
96
  return value;
91
97
  }
@@ -240,6 +240,12 @@ html[data-theme="dark"] .ea-aside {
240
240
  line-height: 1.5;
241
241
  }
242
242
 
243
+ .ea-approval__review {
244
+ max-height: 50vh;
245
+ overflow-y: auto;
246
+ margin-bottom: calc(var(--base) * 0.6);
247
+ }
248
+
243
249
  .ea-approval__hint {
244
250
  margin: 0 0 calc(var(--base) * 0.4);
245
251
  color: var(--theme-elevation-500);
@@ -479,7 +485,7 @@ html[data-theme="dark"] .ea-trigger--open .ea-trigger__icon {
479
485
  padding: calc(var(--base) * 0.5) calc(var(--base) * 0.6);
480
486
  border: 1px solid var(--theme-elevation-150);
481
487
  border-radius: var(--style-radius-m);
482
- background: var(--theme-elevation-50);
488
+ background: var(--theme-elevation-0);
483
489
  font-size: 13px;
484
490
  line-height: 1.45;
485
491
  }
@@ -504,10 +510,17 @@ html[data-theme="dark"] .ea-trigger--open .ea-trigger__icon {
504
510
  }
505
511
 
506
512
  .ea-batch__card-head {
513
+ position: sticky;
514
+ top: 0;
515
+ z-index: 1;
507
516
  display: flex;
508
517
  align-items: center;
509
518
  gap: calc(var(--base) * 0.4);
510
519
  min-width: 0;
520
+ margin: calc(var(--base) * -0.5) calc(var(--base) * -0.6) 0;
521
+ padding: calc(var(--base) * 0.5) calc(var(--base) * 0.6);
522
+ border-bottom: 1px solid var(--theme-elevation-150);
523
+ background: var(--theme-elevation-0);
511
524
  }
512
525
 
513
526
  .ea-batch__card-head input[type="checkbox"] {
@@ -549,29 +562,120 @@ html[data-theme="dark"] .ea-trigger--open .ea-trigger__icon {
549
562
  font-size: 12px;
550
563
  }
551
564
 
552
- .ea-batch__lines {
553
- margin: calc(var(--base) * 0.4) 0 0;
554
- padding-left: 1.1em;
565
+ .ea-review {
566
+ display: flex;
567
+ flex-direction: column;
568
+ gap: 16px;
569
+ margin-top: 16px;
570
+ font-size: 13px;
571
+ line-height: 1.5;
572
+ }
573
+
574
+ .ea-review__collection > h4 {
575
+ margin: 0 0 8px;
576
+ color: var(--theme-elevation-600);
577
+ font-size: 11px;
578
+ font-weight: 600;
579
+ letter-spacing: 0.06em;
580
+ text-transform: uppercase;
555
581
  }
556
582
 
557
- .ea-batch__edit {
583
+ .ea-review__children {
558
584
  display: flex;
559
585
  flex-direction: column;
560
- gap: 2px;
561
- margin-inline-start: -1.1em;
562
- list-style: none;
586
+ gap: 16px;
587
+ min-width: 0;
563
588
  }
564
589
 
565
- .ea-batch__label {
590
+ .ea-review__group {
591
+ padding-top: 12px;
592
+ border-top: 1px solid var(--theme-elevation-150);
593
+ }
594
+
595
+ .ea-review__group > summary {
596
+ display: flex;
597
+ align-items: center;
598
+ gap: 8px;
599
+ cursor: pointer;
600
+ color: var(--theme-text);
601
+ font-size: 14px;
566
602
  font-weight: 600;
603
+ list-style: none;
604
+ }
605
+
606
+ .ea-review__group > summary::-webkit-details-marker {
607
+ display: none;
567
608
  }
568
609
 
569
- .ea-batch__before {
610
+ .ea-review__group > summary::before {
611
+ content: "";
612
+ width: 5px;
613
+ height: 5px;
614
+ flex-shrink: 0;
615
+ border-right: 1.5px solid currentColor;
616
+ border-bottom: 1.5px solid currentColor;
617
+ transform: rotate(-45deg);
618
+ transition: transform 100ms;
619
+ }
620
+
621
+ .ea-review__group[open] > summary::before {
622
+ transform: rotate(45deg);
623
+ }
624
+
625
+ .ea-review__group > summary:focus-visible {
626
+ outline: 2px solid var(--ea-accent);
627
+ outline-offset: 3px;
628
+ }
629
+
630
+ .ea-review__group > .ea-review__children {
631
+ margin-top: 14px;
632
+ }
633
+
634
+ .ea-review__group--row,
635
+ .ea-review__group--group {
636
+ padding-top: 0;
637
+ padding-inline-start: 10px;
638
+ border-top: 0;
639
+ border-inline-start: 2px solid var(--theme-elevation-150);
640
+ }
641
+
642
+ .ea-review__group--row > summary,
643
+ .ea-review__group--group > summary {
644
+ font-size: 12px;
645
+ }
646
+
647
+ .ea-review__badge {
648
+ margin-inline-start: auto;
570
649
  color: var(--theme-elevation-600);
571
- overflow-wrap: anywhere;
650
+ font-size: 11px;
651
+ font-weight: 400;
652
+ }
653
+
654
+ .ea-review__field {
655
+ display: flex;
656
+ flex-direction: column;
657
+ gap: 6px;
658
+ min-width: 0;
659
+ }
660
+
661
+ .ea-review__field-head {
662
+ display: flex;
663
+ align-items: center;
664
+ justify-content: space-between;
665
+ gap: 8px;
666
+ }
667
+
668
+ .ea-review__field-head label {
669
+ font-size: 12px;
670
+ font-weight: 600;
671
+ }
672
+
673
+ .ea-review__new {
674
+ color: var(--theme-elevation-500);
675
+ font-size: 10px;
572
676
  }
573
677
 
574
- .ea-batch__edit textarea {
678
+ .ea-review__field textarea {
575
679
  box-sizing: border-box;
576
680
  width: 100%;
577
681
  padding: calc(var(--base) * 0.3) calc(var(--base) * 0.4);
@@ -586,12 +690,111 @@ html[data-theme="dark"] .ea-trigger--open .ea-trigger__icon {
586
690
  min-height: calc(2lh + calc(var(--base) * 0.6));
587
691
  }
588
692
 
589
- .ea-batch__count {
693
+ .ea-review__field textarea:focus-visible {
694
+ outline: 2px solid var(--ea-accent);
695
+ outline-offset: 1px;
696
+ }
697
+
698
+ .ea-review__count {
590
699
  align-self: flex-end;
591
700
  color: var(--theme-elevation-500);
592
701
  font-size: 11px;
593
702
  }
594
703
 
704
+ .ea-review__current {
705
+ color: var(--theme-elevation-600);
706
+ font-size: 12px;
707
+ }
708
+
709
+ .ea-review__current > summary {
710
+ cursor: pointer;
711
+ }
712
+
713
+ .ea-review__current > p,
714
+ .ea-review__value {
715
+ margin: 0;
716
+ white-space: pre-wrap;
717
+ overflow-wrap: anywhere;
718
+ }
719
+
720
+ .ea-review__current > p,
721
+ .ea-review__current > .ea-review__rich {
722
+ margin-top: 8px;
723
+ }
724
+
725
+ .ea-review__preview,
726
+ .ea-review__value {
727
+ padding: 12px;
728
+ border: 1px solid var(--theme-elevation-150);
729
+ border-radius: var(--style-radius-s);
730
+ background: var(--theme-elevation-50);
731
+ }
732
+
733
+ .ea-review__rich {
734
+ color: var(--theme-text);
735
+ line-height: 1.65;
736
+ overflow-wrap: anywhere;
737
+ }
738
+
739
+ .ea-review__rich > :first-child {
740
+ margin-top: 0;
741
+ }
742
+
743
+ .ea-review__rich > :last-child {
744
+ margin-bottom: 0;
745
+ }
746
+
747
+ .ea-review__rich p {
748
+ margin: 0 0 12px;
749
+ white-space: pre-wrap;
750
+ }
751
+
752
+ .ea-review__rich h1,
753
+ .ea-review__rich h2,
754
+ .ea-review__rich h3,
755
+ .ea-review__rich h4,
756
+ .ea-review__rich h5,
757
+ .ea-review__rich h6 {
758
+ margin: 16px 0 8px;
759
+ font-size: 14px;
760
+ font-weight: 600;
761
+ line-height: 1.4;
762
+ }
763
+
764
+ .ea-review__rich ul,
765
+ .ea-review__rich ol {
766
+ margin: 0 0 12px;
767
+ padding-inline-start: 20px;
768
+ }
769
+
770
+ .ea-review__rich li + li {
771
+ margin-top: 4px;
772
+ }
773
+
774
+ .ea-review__rich blockquote {
775
+ margin: 12px 0;
776
+ padding-inline-start: 12px;
777
+ border-inline-start: 2px solid var(--theme-elevation-250);
778
+ }
779
+
780
+ .ea-review__link {
781
+ text-decoration: underline;
782
+ text-underline-offset: 2px;
783
+ }
784
+
785
+ .ea-review__embedded {
786
+ display: block;
787
+ padding: 8px;
788
+ border: 1px dashed var(--theme-elevation-250);
789
+ color: var(--theme-elevation-600);
790
+ font-size: 12px;
791
+ }
792
+
793
+ .ea-review__change p {
794
+ margin: 0;
795
+ white-space: pre-wrap;
796
+ }
797
+
595
798
  .ea-batch__text,
596
799
  .ea-batch__hint {
597
800
  margin: calc(var(--base) * 0.3) 0 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",