@simmalugnt-se/payload-editor-assistant 0.3.3 → 0.5.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,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - `next` is a required peer dependency: the panel imports `next/navigation`, so it never worked
6
+ without it.
7
+
8
+ - Makes room in Admin: the panel is 320–400px wide depending on the window, and where fewer than
9
+ 1440px would be left for Admin, Payload's nav and the panel take turns. Opening one closes the
10
+ other, which comes back when the first closes. While the assistant answers or a proposal waits for
11
+ Keep or Undo, opening the nav leaves the panel open.
12
+ - Works with the content health work list: `?ask=<prompt>` on a document's address opens the
13
+ assistant with the prompt in the message box (the editor sends it), and a `content-health:select`
14
+ event selects a field like a preview click. The allowlisted collections are published as
15
+ `config.admin.custom.editorAssistant.collections`.
16
+
17
+ - When the editor chats in one language and edits content in another, the first proposal says so
18
+ once, in a sentence the plugin adds; the model is no longer asked to, since it repeated it in
19
+ every reply or said it only in a later one.
20
+ - The reply to a proposal offers it ("Jag föreslår en varmare rubrik.") instead of describing it as
21
+ done: the prompt asks for that, and a sentence saying a field "är nu"/"is now" something is left
22
+ out.
23
+
24
+ ## 0.4.0
25
+
26
+ - A suggestion in the form stays open while the editor writes on, so "shorter" or "another tone"
27
+ adjusts it instead of keeping it first. The preview shows the adjusted version, **Undo** takes the
28
+ form back to before the first suggestion, and **Keep** confirms every step in order on the server,
29
+ each against the form it was made for, so the audit log shows them all. Leaving the form or
30
+ starting a draft still keeps an open suggestion first.
31
+ - The conversation is kept per document: each document and global has its own thread, and lists and
32
+ the dashboard share one. Threads are stored in the browser (localStorage) per user, survive a
33
+ reload, hold the last 50 messages and are forgotten after 30 days; **New chat** clears the current
34
+ one. A new document's thread becomes the document's own when it is first saved. Switching document
35
+ stops an unfinished request and closes an open proposal card, which belongs to the other form.
36
+ - While the assistant works, the panel says what it is doing ("Läser formuläret…", "Förbereder
37
+ ändringen…", "Skriver svaret…") instead of only "Working…". A form proposal goes into the form and
38
+ Live Preview as soon as it exists, before the reply is written. The chat endpoint answers as NDJSON
39
+ events when the request asks for `stream: true`; without it, it answers JSON as before. The reply
40
+ text itself still arrives whole, after the checks it goes through.
41
+ - Everything said in the thread follows the language the editor writes in, including the panel's
42
+ own messages ("Kept", "Undone", errors). Buttons, headings and hints still follow Admin's language.
43
+ - With a form proposal the model's own short sentence is shown ("Här är en kortare rubrik.") instead
44
+ of the same fixed line every time. Sentences that say the change is saved, published or kept, or
45
+ that tell the editor to approve or click, are left out; if nothing is left, the fixed line is used.
46
+ Draft creation keeps its fixed line, since nothing exists until it is approved.
47
+ - Undo reliably discards the proposals on the server: they are sent one at a time, as a second
48
+ request at once replaced the CSRF cookie the first one needed.
49
+ - A reply that says it put a change in the form ("har lagt in", "I've put") without making one is
50
+ retried like other claimed changes.
51
+
3
52
  ## 0.3.3
4
53
 
5
54
  - A `model` function may return `null` or `undefined`, for example when no API key is set. The drawer
package/README.md CHANGED
@@ -89,11 +89,13 @@ secrets (`password`, `token`, `apiKey`, ...) are always off limits.
89
89
  A proposed form change goes straight into the open form, so the fields and Live Preview show it
90
90
  before the editor decides. The changed fields are outlined in blue, and the panel offers **Keep**
91
91
  and **Undo**. Undo puts the form back as it was; if the editor typed in the form in the meantime,
92
- the panel asks first, since undo removes that too. Writing another message keeps the change and
93
- builds on it ("shorter", "now the summary").
92
+ the panel asks first, since undo removes that too. Writing another message leaves the suggestion
93
+ open, so the editor can adjust it ("shorter", "now the summary too"): the adjusted version replaces
94
+ it in the preview, and Undo still goes back to the form from before the first suggestion. Leaving the
95
+ form or starting a draft keeps an open suggestion first.
94
96
 
95
- Keep is still checked on the server against the form the change was made for, and logged. A
96
- proposal expires after 30 minutes.
97
+ Keep is still checked on the server against the form the change was made for, and logged; an adjusted
98
+ suggestion is kept step by step, each step against its own form. A proposal expires after 30 minutes.
97
99
 
98
100
  With autosave (`versions.drafts.autosave`) the draft is saved with the change while it is shown;
99
101
  undo saves the draft back. A preview that renders from the saved draft (`RefreshRouteOnSave`)
@@ -110,6 +112,44 @@ clicks in the assistant keep it.
110
112
  Preview clicks need `@simmalugnt-se/payload-visual-editing` (0.2.0 or later), which announces them
111
113
  with a `visual-editing:select` event. Without it the selection follows the form only.
112
114
 
115
+ ## From the content health work list
116
+
117
+ `@simmalugnt-se/payload-content-health` lists content issues and offers "Fix with the assistant" in
118
+ the collections the assistant works in. The link opens the document with the field selected (a
119
+ `content-health:select` event) and `?ask=<prompt>` in the address: the assistant opens with the
120
+ prompt in the message box, and the editor sends it. A link alone never makes the assistant act.
121
+
122
+ The plugin publishes its allowlisted collections as `config.admin.custom.editorAssistant.collections`
123
+ so the work list knows where to offer it; the packages do not import each other.
124
+
125
+ ## Room for the panel
126
+
127
+ The panel takes 320–400px of the window, depending on its width. Payload lays out Admin by the
128
+ window's width and does not know about the panel. Where fewer than 1440px would be left for Admin
129
+ (the width at which Payload closes its nav itself), the nav and the panel take turns: opening the
130
+ panel closes the nav, opening the nav closes the panel, and the other comes back when you close it.
131
+ While the assistant is answering or a proposal waits for **Keep** or **Undo**, opening the nav leaves
132
+ the panel open, so the decision stays in view. Payload's saved nav preference and your own choice to
133
+ show the panel are not changed.
134
+
135
+ Live Preview splits the rest 40/60 between the form and the preview. From a 1440px window that leaves
136
+ a usable form beside the preview; on smaller screens, open the preview in its own window with the
137
+ external link button at the end of Live Preview's toolbar.
138
+
139
+ ## While it works
140
+
141
+ The panel shows what the assistant is doing (reading the form, searching content, preparing a
142
+ change, writing the reply). A proposed form change is put in the form as soon as the assistant has
143
+ made it, before its reply is written; Keep and Undo wait until the turn is over. If a proxy in front
144
+ of Payload buffers responses, the steps arrive all at once at the end, which still works.
145
+
146
+ ## Conversations
147
+
148
+ Each document and global has its own conversation, and lists and the dashboard share one. They are
149
+ kept in the editor's browser (localStorage), per user: the last 50 messages, for 30 days. Nothing is
150
+ stored in the database, so a conversation does not follow the editor to another browser. **New
151
+ chat** clears the current one.
152
+
113
153
  ## Options
114
154
 
115
155
  | Option | |
@@ -3,26 +3,40 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Button, ChevronIcon, useConfig, useLocale, useTranslation, XIcon } from "@payloadcms/ui";
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import { useEffect, useLayoutEffect, useRef, useState, } from "react";
6
+ import { resolveChatLanguage } from "../runtime/language.js";
6
7
  import { parseAdminView } from "../view.js";
7
- import { assistantRequest } from "./assistant-api.js";
8
+ import { assistantRequest, assistantStream } from "./assistant-api.js";
8
9
  import { useAssistantState, } from "./assistant-context.js";
9
10
  import { COPY, uiLanguage } from "./copy.js";
11
+ import { isFormProposal, proposalSteps, refineProposal, replaceLatestStep, } from "./proposal-chain.js";
10
12
  import { KEEP_SELECTION_ATTR } from "./selection-dom.js";
11
13
  export function EditorAssistantAside() {
12
- const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
14
+ const { open, setOpen, status, messages, shortcuts, collections, bridge, formAdapter, pendingApproval, selection, setSelection, prefill, setPrefill, resetConversation, setStatus, setMessages, setPendingApproval, stop, setAbortController, } = useAssistantState();
13
15
  const { config, getEntityConfig } = useConfig();
14
16
  const router = useRouter();
15
17
  const locale = useLocale();
16
18
  const { i18n } = useTranslation();
17
19
  const copy = COPY[uiLanguage(i18n.language)];
20
+ // What is said in the thread follows the language the editor writes in, like the replies;
21
+ // the panel's own controls follow Admin.
22
+ const talk = COPY[resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), i18n.language)];
18
23
  const pathname = usePathname();
19
24
  const view = parseAdminView(pathname ?? "", config.routes.admin);
20
25
  const visibleShortcuts = shortcuts.filter((shortcut) => shortcutMatches(shortcut, view));
21
26
  const [draft, setDraft] = useState("");
27
+ // A ready prompt replaces the draft; it is only sent when the editor sends it.
28
+ useEffect(() => {
29
+ if (prefill !== null) {
30
+ setDraft(prefill);
31
+ setPrefill(null);
32
+ }
33
+ }, [prefill, setPrefill]);
22
34
  const threadRef = useRef(null);
23
35
  /** Whether the editor typed in the form while a preview was in it; undo would discard that. */
24
36
  const editedSinceRef = useRef(false);
25
37
  const [confirmUndo, setConfirmUndo] = useState(false);
38
+ /** What the assistant is doing while a turn runs, from the server's step events. */
39
+ const [step, setStep] = useState(null);
26
40
  const stickToBottomRef = useRef(true);
27
41
  const apiBase = config.routes.api;
28
42
  // Scroll when the thread grows. The values are triggers, not reads.
@@ -62,8 +76,12 @@ export function EditorAssistantAside() {
62
76
  const say = (text) => {
63
77
  setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text }]);
64
78
  };
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) => {
79
+ /**
80
+ * Puts a form proposal into the form so the form and Live Preview show it before it is kept.
81
+ * `basis` is the form the proposal was made for. With a suggestion already in the form, the new
82
+ * one refines it: undo still goes back to the form from before the first.
83
+ */
84
+ const applyPreview = async (pending, basis, open) => {
67
85
  if (pending.capability !== "form.propose" ||
68
86
  !pending.preview ||
69
87
  !formAdapter ||
@@ -77,10 +95,19 @@ export function EditorAssistantAside() {
77
95
  formAdapter.setModified(true);
78
96
  }
79
97
  catch {
98
+ if (open?.applied) {
99
+ // The earlier suggestion is still what the form shows; the refinement is dropped.
100
+ void decideRequest(pending, "reject", undefined).catch(() => undefined);
101
+ say(talk.applyFailed);
102
+ return open;
103
+ }
80
104
  return pending;
81
105
  }
106
+ if (open?.applied) {
107
+ return refineProposal(open, pending, basis);
108
+ }
82
109
  editedSinceRef.current = false;
83
- return { ...pending, applied: { before, beforeModified: bridge.modified } };
110
+ return { ...pending, applied: { before, beforeModified: bridge.modified, basis } };
84
111
  };
85
112
  const restore = async (applied) => {
86
113
  if (!formAdapter) {
@@ -92,10 +119,10 @@ export function EditorAssistantAside() {
92
119
  // is also what refreshes a preview that renders from the saved draft.
93
120
  formAdapter.setModified(autosave || applied.beforeModified);
94
121
  };
95
- const decideRequest = (pending, action, formData) => assistantRequest(apiBase, "/decide", {
122
+ const decideRequest = (step, action, formData) => assistantRequest(apiBase, "/decide", {
96
123
  action,
97
- approvalId: pending.approvalId,
98
- nonce: pending.nonce,
124
+ approvalId: step.approvalId,
125
+ nonce: step.nonce,
99
126
  collection: bridge?.collectionSlug,
100
127
  global: bridge?.globalSlug,
101
128
  documentId: bridge?.documentId,
@@ -103,8 +130,8 @@ export function EditorAssistantAside() {
103
130
  formData,
104
131
  });
105
132
  /**
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.
133
+ * Keeps a previewed change. The server checks each step against the form it was made for and
134
+ * logs it, oldest first; the form already shows the change, so nothing is applied again.
108
135
  */
109
136
  const keepPreview = async (pending, quiet = false) => {
110
137
  const applied = pending.applied;
@@ -113,14 +140,18 @@ export function EditorAssistantAside() {
113
140
  }
114
141
  setPendingApproval(null);
115
142
  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;
143
+ const steps = proposalSteps(pending);
144
+ for (const [index, step] of steps.entries()) {
145
+ const result = await decideRequest(step, "approve", step.basis).catch(() => null);
146
+ if (!result?.ok) {
147
+ await restore(applied).catch(() => undefined);
148
+ void rejectSteps(steps.slice(index + 1));
149
+ say(talk.keepFailed);
150
+ return false;
151
+ }
121
152
  }
122
153
  if (!quiet) {
123
- say(autosave ? copy.keptAutosave : copy.kept);
154
+ say(autosave ? talk.keptAutosave : talk.kept);
124
155
  }
125
156
  return true;
126
157
  };
@@ -137,24 +168,33 @@ export function EditorAssistantAside() {
137
168
  setConfirmUndo(false);
138
169
  try {
139
170
  await restore(applied);
140
- say(copy.undone);
171
+ say(talk.undone);
141
172
  }
142
173
  catch {
143
- say(copy.applyFailed);
174
+ say(talk.applyFailed);
175
+ }
176
+ void rejectSteps(proposalSteps(pending));
177
+ };
178
+ /**
179
+ * One at a time: each request fetches a CSRF token and replaces the cookie, so a second request
180
+ * started alongside would leave the first with a token that no longer matches.
181
+ */
182
+ const rejectSteps = async (steps) => {
183
+ for (const step of steps) {
184
+ await decideRequest(step, "reject", undefined).catch(() => undefined);
144
185
  }
145
- void decideRequest(pending, "reject", undefined).catch(() => undefined);
146
186
  };
147
187
  const send = async (text) => {
148
188
  const trimmed = text.trim();
149
189
  if (!trimmed || status === "streaming") {
150
190
  return;
151
191
  }
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
- }
192
+ // A suggestion in the form stays open while the editor writes on: the next one refines it.
193
+ const open = pendingApproval?.applied ? pendingApproval : null;
194
+ const basis = formAdapter?.getData();
156
195
  stickToBottomRef.current = true;
157
196
  setDraft("");
197
+ setStep(null);
158
198
  const userMessage = { id: crypto.randomUUID(), role: "user", text: trimmed };
159
199
  setMessages((current) => [...current, userMessage]);
160
200
  const controller = new AbortController();
@@ -165,16 +205,42 @@ export function EditorAssistantAside() {
165
205
  role: message.role,
166
206
  text: message.text,
167
207
  }));
168
- const result = await assistantRequest(apiBase, "/chat", {
208
+ // A form proposal arrives before the reply is written and goes into the form right away.
209
+ // Proposals are applied one after another; a later one in the same turn replaces the earlier.
210
+ let shown = null;
211
+ let applying = Promise.resolve();
212
+ const showProposal = (next) => {
213
+ applying = applying.then(async () => {
214
+ if (shown?.approvalId === next.approvalId || !basis) {
215
+ return;
216
+ }
217
+ shown = shown?.applied
218
+ ? await replacePreview(shown, next, basis)
219
+ : await applyPreview(next, basis, open);
220
+ setPendingApproval(shown);
221
+ });
222
+ return applying;
223
+ };
224
+ const onEvent = (event) => {
225
+ if (event.type === "step" && typeof event.step === "string") {
226
+ setStep(event.step);
227
+ }
228
+ if (event.type === "proposal" && isFormProposal(event.pendingApproval)) {
229
+ void showProposal(event.pendingApproval);
230
+ }
231
+ };
232
+ const result = await assistantStream(apiBase, "/chat", {
169
233
  message: trimmed,
170
234
  messages: history,
171
235
  bridge,
172
236
  view,
173
237
  locale: locale.code,
174
238
  uiLanguage: i18n.language,
175
- formData: formAdapter?.getData(),
239
+ formData: basis,
176
240
  selection: selection?.path,
177
- }, controller.signal);
241
+ openProposal: open?.preview ? { changedPaths: open.preview.changedPaths } : undefined,
242
+ }, onEvent, controller.signal);
243
+ await applying;
178
244
  if (controller.signal.aborted) {
179
245
  setStatus("aborted");
180
246
  return;
@@ -187,22 +253,35 @@ export function EditorAssistantAside() {
187
253
  {
188
254
  id: crypto.randomUUID(),
189
255
  role: "assistant",
190
- text: result.message ?? copy.requestFailed,
256
+ text: result.message ?? talk.requestFailed,
191
257
  },
192
258
  ]);
193
259
  return;
194
260
  }
195
261
  setStatus("idle");
196
- if (result.data.pendingApproval) {
197
- setPendingApproval(await applyPreview(result.data.pendingApproval));
198
- }
199
262
  const navigate = result.data.navigate;
263
+ const next = result.data.pendingApproval;
264
+ if (isFormProposal(next) && basis) {
265
+ await showProposal(next);
266
+ }
267
+ else {
268
+ // Leaving the form or starting a draft ends the open suggestion: keep it first, as before.
269
+ const chain = shown ?? open;
270
+ if (chain?.applied && (next || (navigate && "href" in navigate))) {
271
+ if (!(await keepPreview(chain, true))) {
272
+ return;
273
+ }
274
+ }
275
+ if (next) {
276
+ setPendingApproval(next);
277
+ }
278
+ }
200
279
  setMessages((current) => [
201
280
  ...current,
202
281
  {
203
282
  id: crypto.randomUUID(),
204
283
  role: "assistant",
205
- text: result.data.text ?? copy.done,
284
+ text: result.data.text ?? talk.done,
206
285
  candidates: navigate && "candidates" in navigate ? navigate.candidates : undefined,
207
286
  },
208
287
  ]);
@@ -215,7 +294,24 @@ export function EditorAssistantAside() {
215
294
  }
216
295
  finally {
217
296
  setAbortController(null);
297
+ setStep(null);
298
+ }
299
+ };
300
+ /** A second proposal in one turn takes the first one's place in the form and on the server. */
301
+ const replacePreview = async (current, next, basis) => {
302
+ if (!formAdapter || !next.preview) {
303
+ return current;
304
+ }
305
+ try {
306
+ await formAdapter.reset(next.preview.data);
307
+ formAdapter.setModified(true);
308
+ }
309
+ catch {
310
+ void decideRequest(next, "reject", undefined).catch(() => undefined);
311
+ return current;
218
312
  }
313
+ void decideRequest(current, "reject", undefined).catch(() => undefined);
314
+ return replaceLatestStep(current, next, basis);
219
315
  };
220
316
  const decide = async (action) => {
221
317
  if (!pendingApproval || status === "streaming") {
@@ -256,7 +352,7 @@ export function EditorAssistantAside() {
256
352
  {
257
353
  id: crypto.randomUUID(),
258
354
  role: "assistant",
259
- text: result.message ?? copy.proposalFailed,
355
+ text: result.message ?? talk.proposalFailed,
260
356
  },
261
357
  ]);
262
358
  return;
@@ -270,8 +366,8 @@ export function EditorAssistantAside() {
270
366
  id: crypto.randomUUID(),
271
367
  role: "assistant",
272
368
  text: pendingApproval.capability === "draft.create"
273
- ? copy.rejectedDraft
274
- : copy.rejectedForm,
369
+ ? talk.rejectedDraft
370
+ : talk.rejectedForm,
275
371
  },
276
372
  ]);
277
373
  return;
@@ -285,7 +381,7 @@ export function EditorAssistantAside() {
285
381
  {
286
382
  id: crypto.randomUUID(),
287
383
  role: "assistant",
288
- text: copy.applied,
384
+ text: talk.applied,
289
385
  },
290
386
  ]);
291
387
  }
@@ -296,7 +392,7 @@ export function EditorAssistantAside() {
296
392
  {
297
393
  id: crypto.randomUUID(),
298
394
  role: "assistant",
299
- text: copy.applyFailed,
395
+ text: talk.applyFailed,
300
396
  },
301
397
  ]);
302
398
  }
@@ -311,7 +407,7 @@ export function EditorAssistantAside() {
311
407
  {
312
408
  id: crypto.randomUUID(),
313
409
  role: "assistant",
314
- text: copy.draftCreated,
410
+ text: talk.draftCreated,
315
411
  },
316
412
  ]);
317
413
  router.push(href);
@@ -340,11 +436,11 @@ export function EditorAssistantAside() {
340
436
  }
341
437
  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: () => setOpen(false), 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: () => {
342
438
  router.push(candidate.href);
343
- }, 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 })] })) : 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(" ") ? (
439
+ }, 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(" ") ? (
344
440
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
345
441
  _jsx("li", { className: "ea-approval__sub", children: line.trim() }, index)) : (
346
442
  // biome-ignore lint/suspicious/noArrayIndexKey: lines are static for one approval
347
- _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: () => {
443
+ _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 ? (_jsx("div", { className: "ea-shortcuts", children: visibleShortcuts.map((shortcut) => (_jsx(Button, { buttonStyle: "pill", disabled: status === "streaming", margin: false, onClick: () => {
348
444
  void send(shortcut.prompt);
349
445
  }, 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: status === "streaming" ? (
350
446
  // While the assistant works, Send becomes Stop, where the editor's eye already is.
@@ -2,16 +2,18 @@
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";
5
+ import { ASK_PARAM, CONTENT_HEALTH_SELECT_EVENT, selectionFromElement, selectionFromPath, VISUAL_EDITING_SELECT_EVENT, } from "./selection-dom.js";
6
6
  /** Collapsed rows render their labels only once visual editing has opened them. */
7
7
  const RELABEL_MS = [150, 500, 1200];
8
8
  export function EditorAssistantDocumentBridge() {
9
- const { setBridge, setFormAdapter, setSelection } = useAssistantState();
9
+ const { setBridge, setFormAdapter, setOpen, setPrefill, setSelection } = useAssistantState();
10
10
  const documentInfo = useDocumentInfo();
11
11
  const locale = useLocale();
12
12
  const modified = useFormModified();
13
13
  const form = useDocumentForm();
14
14
  const formRef = useRef(form);
15
+ // Kept across effect re-runs: the parameter is gone from the address after the first read.
16
+ const asked = useRef(false);
15
17
  useEffect(() => {
16
18
  formRef.current = form;
17
19
  });
@@ -25,8 +27,27 @@ export function EditorAssistantDocumentBridge() {
25
27
  setFormAdapter(null);
26
28
  };
27
29
  }, [setFormAdapter]);
30
+ // A work list link (`?ask=`) opens the panel with a ready prompt. The editor sends it, so a
31
+ // link alone never makes the assistant act. The parameter is removed once read.
32
+ useEffect(() => {
33
+ if (asked.current) {
34
+ return;
35
+ }
36
+ asked.current = true;
37
+ const url = new URL(window.location.href);
38
+ const prompt = url.searchParams.get(ASK_PARAM)?.trim();
39
+ if (prompt === undefined) {
40
+ return;
41
+ }
42
+ url.searchParams.delete(ASK_PARAM);
43
+ window.history.replaceState(window.history.state, "", url);
44
+ if (prompt) {
45
+ setPrefill(prompt);
46
+ setOpen(true);
47
+ }
48
+ }, [setOpen, setPrefill]);
28
49
  // "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;
50
+ // in the form, (through visual editing) in the preview, or brought into view by content health. Clicks elsewhere in Admin clear it;
30
51
  // clicks in the assistant itself keep it, so the editor can click, then type.
31
52
  useEffect(() => {
32
53
  let relabel = [];
@@ -65,11 +86,13 @@ export function EditorAssistantDocumentBridge() {
65
86
  document.addEventListener("pointerdown", onInteract, true);
66
87
  document.addEventListener("focusin", onInteract);
67
88
  window.addEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
89
+ window.addEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
68
90
  return () => {
69
91
  cancelRelabel();
70
92
  document.removeEventListener("pointerdown", onInteract, true);
71
93
  document.removeEventListener("focusin", onInteract);
72
94
  window.removeEventListener(VISUAL_EDITING_SELECT_EVENT, onPreviewSelect);
95
+ window.removeEventListener(CONTENT_HEALTH_SELECT_EVENT, onPreviewSelect);
73
96
  setSelection(null);
74
97
  };
75
98
  }, [setSelection]);
@@ -5,11 +5,13 @@ import "../styles/assistant.css";
5
5
  import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
6
6
  import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
7
7
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
8
+ import { useNavRoom } from "./nav-room.js";
8
9
  export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
9
10
  const { user } = useAuth();
10
- return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
11
+ return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
11
12
  }
12
13
  function EditorAssistantShell({ children }) {
13
14
  const { open } = useAssistantState();
15
+ useNavRoom();
14
16
  return (_jsxs("div", { className: open ? "ea-root" : "ea-root ea-root--closed", children: [_jsx("div", { className: "ea-main", children: children }), _jsx(EditorAssistantAside, {}), _jsx(ChangedFieldsHighlight, {})] }));
15
17
  }
@@ -7,3 +7,23 @@ export declare function assistantRequest<T>(apiBase: string, path: string, body:
7
7
  message?: string;
8
8
  status: number;
9
9
  }>;
10
+ /**
11
+ * Like `assistantRequest`, for a turn answered as NDJSON: `onEvent` gets each `step` and `proposal`
12
+ * event as it arrives, and the result is the final `done` (or `error`) event. A server that answers
13
+ * with plain JSON instead still works.
14
+ */
15
+ export declare function assistantStream<T>(apiBase: string, path: string, body: Record<string, unknown>, onEvent: (event: StreamEvent) => void, signal?: AbortSignal): Promise<{
16
+ ok: true;
17
+ data: T;
18
+ } | {
19
+ ok: false;
20
+ error: string;
21
+ message?: string;
22
+ status: number;
23
+ }>;
24
+ export type StreamEvent = {
25
+ type: string;
26
+ [key: string]: unknown;
27
+ };
28
+ /** Calls `handle` with each JSON object line; chunks may split a line anywhere. */
29
+ export declare function readNdjson(body: ReadableStream<Uint8Array>, handle: (event: StreamEvent) => void): Promise<void>;