@simmalugnt-se/payload-editor-assistant 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +29 -3
  3. package/dist/collections/approvals.js +3 -2
  4. package/dist/collections/audit.js +1 -1
  5. package/dist/components/ChangedFieldsHighlight.d.ts +8 -0
  6. package/dist/components/ChangedFieldsHighlight.js +50 -0
  7. package/dist/components/EditorAssistantAside.js +169 -63
  8. package/dist/components/EditorAssistantDocumentBridge.js +52 -1
  9. package/dist/components/EditorAssistantProvider.js +2 -1
  10. package/dist/components/assistant-context.d.ts +14 -0
  11. package/dist/components/assistant-context.js +4 -0
  12. package/dist/components/copy.d.ts +109 -0
  13. package/dist/components/copy.js +109 -0
  14. package/dist/components/selection-dom.d.ts +16 -0
  15. package/dist/components/selection-dom.js +104 -0
  16. package/dist/domain/propose.d.ts +7 -1
  17. package/dist/domain/propose.js +8 -3
  18. package/dist/endpoints/chat.js +2 -0
  19. package/dist/form/changed-paths.d.ts +6 -0
  20. package/dist/form/changed-paths.js +26 -0
  21. package/dist/form/diff.js +1 -22
  22. package/dist/form/empty.js +1 -19
  23. package/dist/form/lexical.d.ts +8 -0
  24. package/dist/form/lexical.js +24 -0
  25. package/dist/runtime/agent.d.ts +4 -1
  26. package/dist/runtime/agent.js +21 -8
  27. package/dist/runtime/context.d.ts +3 -0
  28. package/dist/runtime/context.js +10 -3
  29. package/dist/runtime/propose-loop.d.ts +9 -0
  30. package/dist/runtime/propose-loop.js +13 -1
  31. package/dist/runtime/selection.d.ts +28 -0
  32. package/dist/runtime/selection.js +87 -0
  33. package/dist/runtime/system-prompt.js +17 -1
  34. package/dist/runtime/tools.d.ts +7 -0
  35. package/dist/runtime/tools.js +2 -1
  36. package/dist/runtime/voice.js +2 -2
  37. package/dist/schema/fields.d.ts +1 -0
  38. package/dist/schema/fields.js +1 -0
  39. package/dist/styles/assistant.css +210 -181
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Keep or undo: a proposed form change goes into the form right away, so the fields and Live Preview
6
+ show it, with the changed fields outlined. **Keep** and **Undo** replace Approve and Reject for
7
+ form changes; undo asks first if the editor edited the form since. Writing another message keeps
8
+ the change and builds on it. Keep is still checked and logged on the server. Draft creation is
9
+ unchanged.
10
+ - Proposals expire after 30 minutes instead of 2.
11
+ - Everything the panel says itself (buttons, labels, the proposal card, errors) follows Admin's
12
+ language, Swedish or English; the assistant's replies follow the language the editor writes in.
13
+ Errors read as sentences instead of codes such as `provider_error`.
14
+ - With a single field selected the assistant changes only that field; with a block or row selected,
15
+ only fields inside it.
16
+ - When the editor asks for a change and the model writes the new value in chat instead of staging
17
+ it ("a tighter version could be "…""), the assistant asks it once more to stage it. A value the
18
+ model volunteers while answering a question stays advice.
19
+ - "This" means what the editor selected. The assistant follows the field, block or array row last
20
+ clicked or focused in the form, or clicked in the preview through
21
+ `@simmalugnt-se/payload-visual-editing`, shows it above the message box and keeps changes inside
22
+ it unless asked for more. The server resolves the selection against the fields the assistant may
23
+ see, so hidden fields never reach the model.
24
+ - The drawer looks like the rest of Admin: Payload's buttons, icons, type and greys instead of its
25
+ own, the assistant's replies as plain text, and the document shown as "Page · Home" (with the
26
+ locale only when the project is localized). "Reset" is "New chat" and shows only when there is
27
+ something to clear. The selection, the proposal and the focused message box use visual editing's
28
+ highlight blue, so they read as the same selection as the outline in the preview.
29
+ - A selected rich text field reaches the model as its text rather than Lexical JSON.
30
+ - Selection labels leave out Payload's required and locale markers ("Headline", not "Headline*— sv").
31
+ - Fix: approving a proposal failed on SQLite ("The proposal could not be applied"): the approval
32
+ and audit access rules compared the stored text `actor` with a numeric user id.
33
+
3
34
  ## 0.2.0
4
35
 
5
36
  Breaking: options that were accepted but did nothing are removed.
package/README.md CHANGED
@@ -69,13 +69,39 @@ Nothing is reachable unless you list it. Each collection or global gets the capa
69
69
  | `content.find` | search and list documents | no |
70
70
  | `content.findById` | read one document | no |
71
71
  | `form.read` | read the open edit form, including unsaved changes | no |
72
- | `form.propose` | propose changes to the open form; the editor reviews them before they apply | yes |
72
+ | `form.propose` | put a change in the open form, where the editor keeps or undoes it | yes |
73
73
  | `draft.create` | create a new draft document (collections only) | yes |
74
74
 
75
- The assistant never publishes. Approved form changes land in the open form and the editor saves as
76
- usual; an approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
75
+ The assistant never publishes. Form changes land in the open form and the editor saves as usual; an
76
+ approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
77
77
  secrets (`password`, `token`, `apiKey`, ...) are always off limits.
78
78
 
79
+ ## Keep or undo
80
+
81
+ A proposed form change goes straight into the open form, so the fields and Live Preview show it
82
+ before the editor decides. The changed fields are outlined in blue, and the panel offers **Keep**
83
+ and **Undo**. Undo puts the form back as it was; if the editor typed in the form in the meantime,
84
+ the panel asks first, since undo removes that too. Writing another message keeps the change and
85
+ builds on it ("shorter", "now the summary").
86
+
87
+ Keep is still checked on the server against the form the change was made for, and logged. A
88
+ proposal expires after 30 minutes.
89
+
90
+ With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
91
+ undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
92
+ needs autosave to show the change before it is kept; one that listens to the form
93
+ (`useLivePreview`) shows it either way.
94
+
95
+ ## Selection
96
+
97
+ The editor can click a field, a block or an array row, in the form or in Live Preview, and then write
98
+ "make this shorter". The selection shows above the message box; the assistant treats it as "this"
99
+ and keeps its changes inside it unless asked for more. Clicking elsewhere in Admin clears it, and
100
+ clicks in the assistant keep it.
101
+
102
+ Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
103
+ with a `visual-editing:select` event. Without it the selection follows the form only.
104
+
79
105
  ## Options
80
106
 
81
107
  | Option | |
@@ -5,11 +5,12 @@ export function createApprovalCollection(slug = DEFAULT_APPROVAL_SLUG) {
5
5
  admin: {
6
6
  hidden: true,
7
7
  },
8
+ // `actor` is stored as text; comparing it with a numeric user id matches nothing on SQLite.
8
9
  access: {
9
10
  create: ({ req }) => Boolean(req.user),
10
11
  delete: () => false,
11
- read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
12
- update: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
12
+ read: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
13
+ update: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
13
14
  },
14
15
  fields: [
15
16
  { name: "actor", type: "text", required: true, index: true },
@@ -9,7 +9,7 @@ export function createAuditCollection(slug = DEFAULT_AUDIT_SLUG) {
9
9
  access: {
10
10
  create: ({ req }) => Boolean(req.user),
11
11
  delete: () => false,
12
- read: ({ req }) => (req.user ? { actor: { equals: req.user.id } } : false),
12
+ read: ({ req }) => (req.user ? { actor: { equals: String(req.user.id) } } : false),
13
13
  update: () => false,
14
14
  },
15
15
  fields: [
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Marks the fields a previewed proposal changed, in visual editing's highlight blue, until the
3
+ * editor keeps or undoes it. A stylesheet rather than classes: Payload re-renders fields freely,
4
+ * and ids and `data-field-path` are what stays put. Rows the change sits in get a tinted header,
5
+ * so a change inside a collapsed block is still visible.
6
+ */
7
+ export declare function ChangedFieldsHighlight(): import("react").JSX.Element | null;
8
+ export declare function changedFieldsCss(paths: string[]): string;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useAssistantState } from "./assistant-context.js";
4
+ const ACCENT = "#2563eb";
5
+ const TINT = "rgba(37, 99, 235, 0.08)";
6
+ /**
7
+ * Marks the fields a previewed proposal changed, in visual editing's highlight blue, until the
8
+ * editor keeps or undoes it. A stylesheet rather than classes: Payload re-renders fields freely,
9
+ * and ids and `data-field-path` are what stays put. Rows the change sits in get a tinted header,
10
+ * so a change inside a collapsed block is still visible.
11
+ */
12
+ export function ChangedFieldsHighlight() {
13
+ const { pendingApproval } = useAssistantState();
14
+ const paths = pendingApproval?.applied ? (pendingApproval.preview?.changedPaths ?? []) : [];
15
+ if (paths.length === 0) {
16
+ return null;
17
+ }
18
+ return _jsx("style", { children: changedFieldsCss(paths) });
19
+ }
20
+ export function changedFieldsCss(paths) {
21
+ const fields = new Set();
22
+ const rows = new Set();
23
+ const headers = new Set();
24
+ for (const path of paths) {
25
+ fields.add(`#${CSS.escape(`field-${path.replace(/\./g, "__")}`)}`);
26
+ fields.add(`[data-field-path="${CSS.escape(path)}"]`);
27
+ const segments = path.split(".");
28
+ if (/^\d+$/.test(segments.at(-1) ?? "")) {
29
+ rows.add(`#${CSS.escape(rowId(segments))} > .collapsible`);
30
+ }
31
+ for (let end = 2; end < segments.length; end++) {
32
+ if (/^\d+$/.test(segments[end - 1] ?? "")) {
33
+ headers.add(`#${CSS.escape(rowId(segments.slice(0, end)))} > .collapsible > .collapsible__toggle-wrap`);
34
+ }
35
+ }
36
+ }
37
+ const rules = [
38
+ fields.size > 0 &&
39
+ `${[...fields].join(",\n")} { outline: 2px solid ${ACCENT}; outline-offset: 2px; background-color: ${TINT}; }`,
40
+ rows.size > 0 &&
41
+ `${[...rows].join(",\n")} { outline: 2px solid ${ACCENT}; outline-offset: 2px; }`,
42
+ headers.size > 0 &&
43
+ `${[...headers].join(",\n")} { background-color: ${TINT}; box-shadow: inset 3px 0 0 ${ACCENT}; }`,
44
+ ];
45
+ return rules.filter(Boolean).join("\n");
46
+ }
47
+ /** Payload's DOM id for a block or array row: ["layout", "2", "items", "0"] → "layout-2-items-row-0". */
48
+ function rowId(segments) {
49
+ return `${segments.slice(0, -1).join("-")}-row-${segments.at(-1)}`;
50
+ }
@@ -1,59 +1,28 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useConfig, useLocale, useTranslation } from "@payloadcms/ui";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, ChevronIcon, useConfig, useLocale, useTranslation, XIcon } from "@payloadcms/ui";
4
4
  import { usePathname, useRouter } from "next/navigation";
5
- import { useLayoutEffect, useRef, useState, } from "react";
5
+ import { useEffect, useLayoutEffect, useRef, useState, } from "react";
6
6
  import { parseAdminView } from "../view.js";
7
7
  import { assistantRequest } from "./assistant-api.js";
8
8
  import { useAssistantState, } from "./assistant-context.js";
9
- const STATUS_LABEL = {
10
- idle: "",
11
- unavailable: "unavailable",
12
- unauthorized: "unauthorized",
13
- rate_limited: "rate_limited",
14
- timeout: "timeout",
15
- provider_error: "provider_error",
16
- tool_denied: "tool_denied",
17
- stale_context: "stale_context",
18
- aborted: "aborted",
19
- streaming: "Working…",
20
- };
21
- /** Messages the panel adds to the chat itself; they follow the editor's chat language. */
22
- const CHAT_COPY = {
23
- en: {
24
- requestFailed: "I could not complete that request.",
25
- done: "Done.",
26
- proposalFailed: "The proposal could not be applied.",
27
- rejectedForm: "Rejected. The form was not changed.",
28
- rejectedDraft: "Rejected. No draft was created.",
29
- applied: "Applied to the unsaved form. Save in Payload when you are ready.",
30
- applyFailed: "The form could not be updated. It was left unchanged.",
31
- draftCreated: "The draft is created and open.",
32
- },
33
- sv: {
34
- requestFailed: "Jag kunde inte slutföra det.",
35
- done: "Klart.",
36
- proposalFailed: "Förslaget kunde inte genomföras.",
37
- rejectedForm: "Avvisat. Formuläret ändrades inte.",
38
- rejectedDraft: "Avvisat. Inget utkast skapades.",
39
- applied: "Ändringen är införd i formuläret men inte sparad. Spara i Payload när du är redo.",
40
- applyFailed: "Formuläret kunde inte uppdateras och lämnades orört.",
41
- draftCreated: "Utkastet är skapat och öppnat.",
42
- },
43
- };
9
+ import { COPY, uiLanguage } from "./copy.js";
10
+ import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
44
11
  export function EditorAssistantAside() {
45
- const { collapsed, setCollapsed, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
46
- const { config } = useConfig();
12
+ const { collapsed, setCollapsed, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
13
+ const { config, getEntityConfig } = useConfig();
47
14
  const router = useRouter();
48
15
  const locale = useLocale();
49
16
  const { i18n } = useTranslation();
50
- const [chatLanguage, setChatLanguage] = useState(i18n.language?.startsWith("sv") ? "sv" : "en");
51
- const copy = CHAT_COPY[chatLanguage];
17
+ const copy = COPY[uiLanguage(i18n.language)];
52
18
  const pathname = usePathname();
53
19
  const view = parseAdminView(pathname ?? "", config.routes.admin);
54
20
  const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
55
21
  const [draft, setDraft] = useState("");
56
22
  const threadRef = useRef(null);
23
+ /** Whether the editor typed in the form while a preview was in it; undo would discard that. */
24
+ const editedSinceRef = useRef(false);
25
+ const [confirmUndo, setConfirmUndo] = useState(false);
57
26
  const stickToBottomRef = useRef(true);
58
27
  const apiBase = config.routes.api;
59
28
  // Scroll when the thread grows. The values are triggers, not reads.
@@ -70,11 +39,120 @@ export function EditorAssistantAside() {
70
39
  const remaining = node.scrollHeight - node.scrollTop - node.clientHeight;
71
40
  stickToBottomRef.current = remaining < 48;
72
41
  };
42
+ const autosave = autosaves(getEntityConfig, bridge);
43
+ const previewing = Boolean(pendingApproval?.applied);
44
+ // Edits by the editor, not by autosave: autosave rewrites form metadata, so comparing the
45
+ // form's data would see changes nobody made.
46
+ useEffect(() => {
47
+ if (!previewing) {
48
+ return;
49
+ }
50
+ const onEdit = (event) => {
51
+ if (event.target instanceof Element && !event.target.closest(".ea-aside")) {
52
+ editedSinceRef.current = true;
53
+ }
54
+ };
55
+ document.addEventListener("input", onEdit, true);
56
+ document.addEventListener("change", onEdit, true);
57
+ return () => {
58
+ document.removeEventListener("input", onEdit, true);
59
+ document.removeEventListener("change", onEdit, true);
60
+ };
61
+ }, [previewing]);
62
+ const say = (text) => {
63
+ setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text }]);
64
+ };
65
+ /** Puts a form proposal into the form so the form and Live Preview show it before it is kept. */
66
+ const applyPreview = async (pending) => {
67
+ if (pending.capability !== "form.propose" ||
68
+ !pending.preview ||
69
+ !formAdapter ||
70
+ !bridge ||
71
+ !canApplyForm(bridge)) {
72
+ return pending;
73
+ }
74
+ const before = formAdapter.getData();
75
+ try {
76
+ await formAdapter.reset(pending.preview.data);
77
+ formAdapter.setModified(true);
78
+ }
79
+ catch {
80
+ return pending;
81
+ }
82
+ editedSinceRef.current = false;
83
+ return { ...pending, applied: { before, beforeModified: bridge.modified } };
84
+ };
85
+ const restore = async (applied) => {
86
+ if (!formAdapter) {
87
+ return;
88
+ }
89
+ await formAdapter.reset(applied.before);
90
+ // Autosave has most likely stored the previewed change in the draft already, and it only
91
+ // saves a modified form: marking it modified makes it store the restored values too, which
92
+ // is also what refreshes a preview that renders from the saved draft.
93
+ formAdapter.setModified(autosave || applied.beforeModified);
94
+ };
95
+ const decideRequest = (pending, action, formData) => assistantRequest(apiBase, "/decide", {
96
+ action,
97
+ approvalId: pending.approvalId,
98
+ nonce: pending.nonce,
99
+ collection: bridge?.collectionSlug,
100
+ global: bridge?.globalSlug,
101
+ documentId: bridge?.documentId,
102
+ locale: bridge?.locale ?? "en",
103
+ formData,
104
+ });
105
+ /**
106
+ * Keeps a previewed change. The server checks the proposal against the form it was made for
107
+ * and logs it; the form already shows the change, so nothing is applied again.
108
+ */
109
+ const keepPreview = async (pending, quiet = false) => {
110
+ const applied = pending.applied;
111
+ if (!applied) {
112
+ return false;
113
+ }
114
+ setPendingApproval(null);
115
+ setConfirmUndo(false);
116
+ const result = await decideRequest(pending, "approve", applied.before).catch(() => null);
117
+ if (!result?.ok) {
118
+ await restore(applied).catch(() => undefined);
119
+ say(copy.keepFailed);
120
+ return false;
121
+ }
122
+ if (!quiet) {
123
+ say(autosave ? copy.keptAutosave : copy.kept);
124
+ }
125
+ return true;
126
+ };
127
+ const undoPreview = async (pending, force = false) => {
128
+ const applied = pending.applied;
129
+ if (!applied) {
130
+ return;
131
+ }
132
+ if (editedSinceRef.current && !force) {
133
+ setConfirmUndo(true);
134
+ return;
135
+ }
136
+ setPendingApproval(null);
137
+ setConfirmUndo(false);
138
+ try {
139
+ await restore(applied);
140
+ say(copy.undone);
141
+ }
142
+ catch {
143
+ say(copy.applyFailed);
144
+ }
145
+ void decideRequest(pending, "reject", undefined).catch(() => undefined);
146
+ };
73
147
  const send = async (text) => {
74
148
  const trimmed = text.trim();
75
149
  if (!trimmed || status === "streaming") {
76
150
  return;
77
151
  }
152
+ // Writing on is taken as keeping what is in the form; the next change builds on it.
153
+ if (pendingApproval?.applied && !(await keepPreview(pendingApproval, true))) {
154
+ return;
155
+ }
78
156
  stickToBottomRef.current = true;
79
157
  setDraft("");
80
158
  const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
@@ -95,6 +173,7 @@ export function EditorAssistantAside() {
95
173
  locale: locale.code,
96
174
  uiLanguage: i18n.language,
97
175
  formData: formAdapter?.getData(),
176
+ selection: selection?.path,
98
177
  }, controller.signal);
99
178
  if (controller.signal.aborted) {
100
179
  setStatus("aborted");
@@ -114,11 +193,8 @@ export function EditorAssistantAside() {
114
193
  return;
115
194
  }
116
195
  setStatus("idle");
117
- if (result.data.language === "sv" || result.data.language === "en") {
118
- setChatLanguage(result.data.language);
119
- }
120
196
  if (result.data.pendingApproval) {
121
- setPendingApproval(result.data.pendingApproval);
197
+ setPendingApproval(await applyPreview(result.data.pendingApproval));
122
198
  }
123
199
  const navigate = result.data.navigate;
124
200
  setMessages((current) => [
@@ -145,6 +221,11 @@ export function EditorAssistantAside() {
145
221
  if (!pendingApproval || status === "streaming") {
146
222
  return;
147
223
  }
224
+ if (pendingApproval.applied) {
225
+ stickToBottomRef.current = true;
226
+ await (action === "approve" ? keepPreview(pendingApproval) : undoPreview(pendingApproval));
227
+ return;
228
+ }
148
229
  stickToBottomRef.current = true;
149
230
  if (action === "approve" && pendingApproval.capability === "form.propose") {
150
231
  if (!formAdapter || !bridge || !canApplyForm(bridge)) {
@@ -254,41 +335,53 @@ export function EditorAssistantAside() {
254
335
  }
255
336
  };
256
337
  if (collapsed) {
257
- return (_jsx("aside", { className: "ea-aside ea-aside--collapsed", "aria-label": "Assistant", children: _jsx("button", { className: "ea-aside__rail", type: "button", onClick: () => setCollapsed(false), children: "Open assistant" }) }));
338
+ return (_jsx("aside", { className: "ea-aside ea-aside--collapsed", "aria-label": copy.assistant, children: _jsxs("button", { className: "ea-aside__rail", type: "button", onClick: () => setCollapsed(false), children: [_jsx(ChevronIcon, { direction: "left" }), _jsx("span", { children: copy.assistant })] }) }));
258
339
  }
259
- return (_jsxs("aside", { className: "ea-aside", "aria-label": "Assistant", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsx("h2", { children: "Assistant" }), _jsx("button", { className: "ea-btn ea-btn--ghost", type: "button", onClick: () => resetConversation(), children: "Reset" }), _jsx("button", { className: "ea-btn ea-btn--ghost", type: "button", onClick: () => setCollapsed(true), children: "Collapse" })] }), _jsxs("div", { className: "ea-aside__body", children: [bridge ? (_jsxs("div", { className: "ea-context", children: [_jsx("span", { children: bridge.collectionSlug ?? bridge.globalSlug }), _jsx("span", { children: bridge.title ?? "document" }), _jsx("span", { children: bridge.locale }), _jsx("span", { className: bridge.modified ? "ea-context__warn" : "ea-context__ok", children: bridge.modified ? "unsaved" : "saved" })] })) : null, _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) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx("button", { className: "ea-btn", type: "button", onClick: () => {
340
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setCollapsed(true), tooltip: copy.collapse })] }), _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.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
260
341
  router.push(candidate.href);
261
- }, children: candidate.label }, candidate.href))) })) : null] }, message.id))), status === "streaming" ? (_jsxs("div", { className: "ea-msg ea-msg--working", children: [_jsx("span", { children: STATUS_LABEL.streaming }), _jsx("button", { className: "ea-btn ea-btn--stop", type: "button", onClick: () => stop(), children: "Stop" })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
342
+ }, 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: copy.working }), _jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => stop(), size: "small", children: copy.stop })] })) : null] }), pendingApproval ? (_jsxs("div", { className: "ea-approval", children: [_jsx("h3", { children: pendingApproval.title }), pendingApproval.applied ? (_jsx("p", { className: "ea-approval__hint", children: autosave ? copy.previewHintAutosave : copy.previewHint })) : null, _jsx("ul", { className: "ea-approval__diff", children: pendingApproval.summary.split("\n").map((line, index) => line.startsWith(" ") ? (
262
343
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
263
344
  _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
264
345
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
265
- _jsx("li", { children: line }, index))) }), _jsxs("div", { className: "ea-actions", children: [_jsx("button", { className: "ea-btn ea-btn--primary", type: "button", onClick: () => void decide("approve"), children: "Approve" }), _jsx("button", { className: "ea-btn", type: "button", onClick: () => void decide("reject"), children: "Reject" })] })] })) : null, status !== "idle" && status !== "streaming" ? (_jsx("div", { className: `ea-status ea-status--${status}`, children: STATUS_LABEL[status] })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx("button", { className: "ea-btn ea-btn--chip", disabled: status === "streaming", type: "button", onClick: () => {
346
+ _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", margin: false, onClick: () => void decide("approve"), size: "medium", children: pendingApproval.applied ? copy.keep : copy.approve }), _jsx(Button, { buttonStyle: "secondary", 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, copy) })) : null, _jsxs("form", { className: "ea-composer", onSubmit: onSubmit, children: [visibleShortcuts.length > 0 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
266
347
  void send(shortcut.prompt);
267
- }, children: shortcut.label }, shortcut.id))) })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: "Message the assistant", rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: _jsx("button", { className: "ea-btn ea-btn--primary", disabled: status === "streaming" || draft.trim() === "", type: "submit", children: "Send" }) })] })] })] })] }));
348
+ }, size: "small", children: shortcut.label }, shortcut.id))) })) : null, selection && bridge ? (_jsxs("div", { className: "ea-selection", children: [_jsxs("span", { className: "ea-selection__label", children: [copy.selected, ": ", _jsx("strong", { children: selection.label })] }), _jsx("button", { "aria-label": copy.clearSelection, className: "ea-selection__clear", type: "button", onClick: () => setSelection(null), children: _jsx(XIcon, {}) })] })) : null, _jsxs("div", { className: "ea-composer__box", children: [_jsx("textarea", { disabled: status === "streaming", placeholder: copy.placeholder, rows: 2, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown }), _jsx("div", { className: "ea-composer__bar", children: _jsx(Button, { buttonStyle: "primary", disabled: status === "streaming" || draft.trim() === "", margin: false, size: "medium", type: "submit", children: copy.send }) })] })] })] })] }));
349
+ }
350
+ /** "Page · Home", with the locale when the project has more than one. */
351
+ function contextLine(bridge, config) {
352
+ const collection = config.collections?.find((entry) => entry.slug === bridge.collectionSlug);
353
+ const global = config.globals?.find((entry) => entry.slug === bridge.globalSlug);
354
+ const kind = textLabel(collection?.labels?.singular) ??
355
+ textLabel(global?.label) ??
356
+ bridge.collectionSlug ??
357
+ bridge.globalSlug;
358
+ const parts = [kind, bridge.title];
359
+ if (config.localization) {
360
+ parts.push(bridge.locale.toUpperCase());
361
+ }
362
+ return parts.filter(Boolean).join(" · ");
268
363
  }
269
- function introText(view, bridge, collections, config) {
364
+ function introText(view, bridge, collections, config, copy) {
270
365
  switch (view.kind) {
271
366
  case "document":
272
- case "global": {
273
- if (!bridge) {
274
- return "I can help you find content, open a page, or start a new draft. What do you need?";
275
- }
276
- const name = bridge.title ? `"${bridge.title}"` : "this document";
277
- return `I can explain the fields on ${name}, suggest edits, or fill in empty fields. What do you need?`;
278
- }
367
+ case "global":
368
+ return bridge ? copy.intro.document(bridge.title) : copy.intro.general;
279
369
  case "list": {
280
370
  if (!collections.includes(view.collection)) {
281
- return "I can't work with this list, but I can help you find content, open a page, or start a new draft.";
371
+ return copy.intro.notAllowed;
282
372
  }
283
373
  const collection = config.collections?.find((entry) => entry.slug === view.collection);
284
374
  const plural = textLabel(collection?.labels?.plural) ?? view.collection;
285
- const singular = textLabel(collection?.labels?.singular)?.toLowerCase() ?? "document";
286
- return `You are in ${plural}. I can find and open a ${singular}, or start a new draft for you. What do you need?`;
375
+ const singular = textLabel(collection?.labels?.singular)?.toLowerCase() ?? copy.intro.documentFallback;
376
+ return copy.intro.list(plural, singular);
287
377
  }
288
378
  default:
289
- return "I can help you find content, open a page, or start a new draft. What do you need?";
379
+ return copy.intro.general;
290
380
  }
291
381
  }
382
+ function statusText(status, copy) {
383
+ return status in copy.status ? copy.status[status] : "";
384
+ }
292
385
  function textLabel(value) {
293
386
  return typeof value === "string" && value.trim() ? value : undefined;
294
387
  }
@@ -306,6 +399,19 @@ function shortcutMatches(shortcut, view) {
306
399
  : undefined;
307
400
  return entity === undefined || shortcut.entities.includes(entity);
308
401
  }
402
+ /** Autosave writes the previewed change to the draft, so the hint must not say nothing is saved. */
403
+ function autosaves(getEntityConfig, bridge) {
404
+ if (!bridge) {
405
+ return false;
406
+ }
407
+ const entity = bridge.collectionSlug
408
+ ? getEntityConfig({ collectionSlug: bridge.collectionSlug })
409
+ : bridge.globalSlug
410
+ ? getEntityConfig({ globalSlug: bridge.globalSlug })
411
+ : undefined;
412
+ const drafts = entity?.versions?.drafts;
413
+ return typeof drafts === "object" && Boolean(drafts.autosave);
414
+ }
309
415
  function canApplyForm(bridge) {
310
416
  if (bridge.disabled || bridge.locked) {
311
417
  return false;
@@ -2,8 +2,11 @@
2
2
  import { useDocumentForm, useDocumentInfo, useFormModified, useLocale } from "@payloadcms/ui";
3
3
  import { useEffect, useRef } from "react";
4
4
  import { useAssistantState } from "./assistant-context.js";
5
+ import { selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
6
+ /** Collapsed rows render their labels only once visual editing has opened them. */
7
+ const RELABEL_MS = [150, 500, 1200];
5
8
  export function EditorAssistantDocumentBridge() {
6
- const { setBridge, setFormAdapter } = useAssistantState();
9
+ const { setBridge, setFormAdapter, setSelection } = useAssistantState();
7
10
  const documentInfo = useDocumentInfo();
8
11
  const locale = useLocale();
9
12
  const modified = useFormModified();
@@ -22,6 +25,54 @@ export function EditorAssistantDocumentBridge() {
22
25
  setFormAdapter(null);
23
26
  };
24
27
  }, [setFormAdapter]);
28
+ // "This" in the editor's message is what they last clicked or focused: a field, a block or a row,
29
+ // in the form or (through visual editing) in the preview. Clicks elsewhere in Admin clear it;
30
+ // clicks in the assistant itself keep it, so the editor can click, then type.
31
+ useEffect(() => {
32
+ let relabel = [];
33
+ const cancelRelabel = () => {
34
+ for (const timer of relabel) {
35
+ clearTimeout(timer);
36
+ }
37
+ relabel = [];
38
+ };
39
+ const onInteract = (event) => {
40
+ const target = event.target;
41
+ if (!(target instanceof Element) || target instanceof HTMLIFrameElement) {
42
+ return;
43
+ }
44
+ if (target.closest(".ea-aside")) {
45
+ return;
46
+ }
47
+ cancelRelabel();
48
+ setSelection(selectionFromElement(target) ?? null);
49
+ };
50
+ const onPreviewSelect = (event) => {
51
+ const path = event.detail?.path;
52
+ if (path === null) {
53
+ // The editor cleared the selection in the preview.
54
+ cancelRelabel();
55
+ setSelection(null);
56
+ return;
57
+ }
58
+ if (typeof path !== "string" || !path) {
59
+ return;
60
+ }
61
+ cancelRelabel();
62
+ setSelection(selectionFromPath(path));
63
+ relabel = RELABEL_MS.map((ms) => setTimeout(() => setSelection(selectionFromPath(path)), ms));
64
+ };
65
+ document.addEventListener("pointerdown", onInteract, true);
66
+ document.addEventListener("focusin", onInteract);
67
+ window.addEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
68
+ return () => {
69
+ cancelRelabel();
70
+ document.removeEventListener("pointerdown", onInteract, true);
71
+ document.removeEventListener("focusin", onInteract);
72
+ window.removeEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
73
+ setSelection(null);
74
+ };
75
+ }, [setSelection]);
25
76
  useEffect(() => {
26
77
  const titleValue = documentInfo.data && "title" in documentInfo.data ? documentInfo.data.title : undefined;
27
78
  const locked = Boolean(documentInfo.documentIsLocked) ||
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useAuth } from "@payloadcms/ui";
4
4
  import "../styles/assistant.css";
5
5
  import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
6
+ import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
6
7
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
7
8
  export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
8
9
  const { user } = useAuth();
@@ -10,5 +11,5 @@ export function EditorAssistantProvider({ children, shortcuts = [], collections
10
11
  }
11
12
  function EditorAssistantShell({ children }) {
12
13
  const { collapsed } = useAssistantState();
13
- return (_jsxs("div", { className: collapsed ? "ea-root ea-root--collapsed" : "ea-root", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {})] }));
14
+ return (_jsxs("div", { className: collapsed ? "ea-root ea-root--collapsed" : "ea-root", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
14
15
  }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { AssistantShortcut } from "../types.ts";
3
+ import type { DomSelection } from "./selection-dom.ts";
3
4
  export type AssistantChromeStatus = "idle" | "unavailable" | "unauthorized" | "rate_limited" | "timeout" | "provider_error" | "tool_denied" | "stale_context" | "aborted" | "streaming";
4
5
  export type DocumentBridgeSnapshot = {
5
6
  collectionSlug?: string;
@@ -24,6 +25,16 @@ export type PendingApproval = {
24
25
  approvalId: string;
25
26
  nonce: string;
26
27
  capability: "form.propose" | "draft.create";
28
+ /** The form with the change applied; Admin shows it before the editor keeps it. */
29
+ preview?: {
30
+ data: Record<string, unknown>;
31
+ changedPaths: string[];
32
+ };
33
+ /** Set once the preview is in the form: what to restore on undo. */
34
+ applied?: {
35
+ before: Record<string, unknown>;
36
+ beforeModified: boolean;
37
+ };
27
38
  };
28
39
  export type AssistantMessage = {
29
40
  id: string;
@@ -45,11 +56,14 @@ type AssistantContextValue = {
45
56
  bridge: DocumentBridgeSnapshot | null;
46
57
  formAdapter: FormAdapter | null;
47
58
  pendingApproval: PendingApproval | null;
59
+ /** What the editor last clicked or focused in the open form. */
60
+ selection: DomSelection | null;
48
61
  setOpen: (open: boolean) => void;
49
62
  setCollapsed: (collapsed: boolean) => void;
50
63
  setBridge: (bridge: DocumentBridgeSnapshot | null) => void;
51
64
  setFormAdapter: (adapter: FormAdapter | null) => void;
52
65
  setPendingApproval: (pending: PendingApproval | null) => void;
66
+ setSelection: (selection: DomSelection | null) => void;
53
67
  setStatus: (status: AssistantChromeStatus) => void;
54
68
  setMessages: (updater: (current: AssistantMessage[]) => AssistantMessage[]) => void;
55
69
  resetConversation: () => void;