@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.
@@ -1,5 +1,99 @@
1
1
  import { CSRF_HEADER, ENDPOINT_PREFIX } from "../package-name.js";
2
2
  export async function assistantRequest(apiBase, path, body, signal) {
3
+ const response = await post(apiBase, path, body, signal);
4
+ if (!(response instanceof Response)) {
5
+ return response;
6
+ }
7
+ const payload = (await response.json().catch(() => ({})));
8
+ if (!response.ok) {
9
+ return {
10
+ ok: false,
11
+ error: payload.error ?? "provider_error",
12
+ message: payload.message,
13
+ status: response.status,
14
+ };
15
+ }
16
+ return { ok: true, data: payload };
17
+ }
18
+ /**
19
+ * Like `assistantRequest`, for a turn answered as NDJSON: `onEvent` gets each `step` and `proposal`
20
+ * event as it arrives, and the result is the final `done` (or `error`) event. A server that answers
21
+ * with plain JSON instead still works.
22
+ */
23
+ export async function assistantStream(apiBase, path, body, onEvent, signal) {
24
+ const response = await post(apiBase, path, { ...body, stream: true }, signal);
25
+ if (!(response instanceof Response)) {
26
+ return response;
27
+ }
28
+ const type = response.headers.get("content-type") ?? "";
29
+ if (!response.ok || !response.body || !type.includes("ndjson")) {
30
+ const payload = (await response.json().catch(() => ({})));
31
+ return response.ok
32
+ ? { ok: true, data: payload }
33
+ : {
34
+ ok: false,
35
+ error: payload.error ?? "provider_error",
36
+ message: payload.message,
37
+ status: response.status,
38
+ };
39
+ }
40
+ let final;
41
+ await readNdjson(response.body, (event) => {
42
+ if (event.type === "done") {
43
+ final = { ok: true, data: event.result };
44
+ }
45
+ else if (event.type === "error") {
46
+ final = {
47
+ ok: false,
48
+ error: typeof event.error === "string" ? event.error : "provider_error",
49
+ message: typeof event.message === "string" ? event.message : undefined,
50
+ status: response.status,
51
+ };
52
+ }
53
+ else {
54
+ onEvent(event);
55
+ }
56
+ });
57
+ return final ?? { ok: false, error: "provider_error", status: response.status };
58
+ }
59
+ /** Calls `handle` with each JSON object line; chunks may split a line anywhere. */
60
+ export async function readNdjson(body, handle) {
61
+ const parse = (line) => {
62
+ if (!line.trim()) {
63
+ return;
64
+ }
65
+ try {
66
+ const event = JSON.parse(line);
67
+ if (event && typeof event.type === "string") {
68
+ handle(event);
69
+ }
70
+ }
71
+ catch {
72
+ // A line that is not JSON is skipped.
73
+ }
74
+ };
75
+ const reader = body.getReader();
76
+ const decoder = new TextDecoder();
77
+ let buffered = "";
78
+ for (;;) {
79
+ const { done, value } = await reader.read();
80
+ if (done) {
81
+ break;
82
+ }
83
+ buffered += decoder.decode(value, { stream: true });
84
+ const lines = buffered.split("\n");
85
+ buffered = lines.pop() ?? "";
86
+ for (const line of lines) {
87
+ parse(line);
88
+ }
89
+ }
90
+ parse(buffered + decoder.decode());
91
+ }
92
+ /**
93
+ * Posts with a fresh CSRF token. Fetching one replaces the cookie, so requests must not overlap: the
94
+ * earlier one would carry a token that no longer matches.
95
+ */
96
+ async function post(apiBase, path, body, signal) {
3
97
  const csrfResponse = await fetch(`${apiBase}${ENDPOINT_PREFIX}/csrf`, {
4
98
  credentials: "include",
5
99
  signal,
@@ -11,7 +105,7 @@ export async function assistantRequest(apiBase, path, body, signal) {
11
105
  if (!csrf.token) {
12
106
  return { ok: false, error: "unauthorized", status: 401 };
13
107
  }
14
- const response = await fetch(`${apiBase}${ENDPOINT_PREFIX}${path}`, {
108
+ return fetch(`${apiBase}${ENDPOINT_PREFIX}${path}`, {
15
109
  method: "POST",
16
110
  credentials: "include",
17
111
  signal,
@@ -21,14 +115,4 @@ export async function assistantRequest(apiBase, path, body, signal) {
21
115
  },
22
116
  body: JSON.stringify(body),
23
117
  });
24
- const payload = (await response.json().catch(() => ({})));
25
- if (!response.ok) {
26
- return {
27
- ok: false,
28
- error: payload.error ?? "provider_error",
29
- message: payload.message,
30
- status: response.status,
31
- };
32
- }
33
- return { ok: true, data: payload };
34
118
  }
@@ -30,11 +30,21 @@ export type PendingApproval = {
30
30
  data: Record<string, unknown>;
31
31
  changedPaths: string[];
32
32
  };
33
- /** Set once the preview is in the form: what to restore on undo. */
33
+ /**
34
+ * Set once the preview is in the form: what to restore on undo (the form before the first step)
35
+ * and `basis`, the form this proposal was made for, which keeping it is checked against.
36
+ */
34
37
  applied?: {
35
38
  before: Record<string, unknown>;
36
39
  beforeModified: boolean;
40
+ basis: Record<string, unknown>;
37
41
  };
42
+ /** Earlier proposals this one refines, oldest first; kept or undone together with it. */
43
+ earlier?: Array<{
44
+ approvalId: string;
45
+ nonce: string;
46
+ basis: Record<string, unknown>;
47
+ }>;
38
48
  };
39
49
  export type AssistantMessage = {
40
50
  id: string;
@@ -58,11 +68,17 @@ type AssistantContextValue = {
58
68
  pendingApproval: PendingApproval | null;
59
69
  /** What the editor last clicked or focused in the open form. */
60
70
  selection: DomSelection | null;
61
- setOpen: (open: boolean) => void;
71
+ /** A prompt to put in the message box, e.g. from a work list link; the panel takes it once. */
72
+ prefill: string | null;
73
+ /** `remember: false` when the panel makes way on its own (see nav-room.ts), not the editor. */
74
+ setOpen: (open: boolean, options?: {
75
+ remember?: boolean;
76
+ }) => void;
62
77
  setBridge: (bridge: DocumentBridgeSnapshot | null) => void;
63
78
  setFormAdapter: (adapter: FormAdapter | null) => void;
64
79
  setPendingApproval: (pending: PendingApproval | null) => void;
65
80
  setSelection: (selection: DomSelection | null) => void;
81
+ setPrefill: (prefill: string | null) => void;
66
82
  setStatus: (status: AssistantChromeStatus) => void;
67
83
  setMessages: (updater: (current: AssistantMessage[]) => AssistantMessage[]) => void;
68
84
  resetConversation: () => void;
@@ -70,10 +86,12 @@ type AssistantContextValue = {
70
86
  abortController: AbortController | null;
71
87
  setAbortController: (controller: AbortController | null) => void;
72
88
  };
73
- export declare function AssistantStateProvider({ children, shortcuts, collections, }: {
89
+ export declare function AssistantStateProvider({ children, shortcuts, collections, userId, }: {
74
90
  children: ReactNode;
75
91
  shortcuts: AssistantShortcut[];
76
92
  collections: string[];
93
+ /** Whose conversations the browser keeps; none are kept without a signed-in user. */
94
+ userId?: string | number;
77
95
  }): import("react").JSX.Element;
78
96
  export declare function useAssistantState(): AssistantContextValue;
79
97
  export {};
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { carriesOver, conversationKey, readConversation, sameConversation, writeConversation, } from "./conversation-store.js";
4
5
  const AssistantContext = createContext(null);
5
- export function AssistantStateProvider({ children, shortcuts, collections, }) {
6
+ export function AssistantStateProvider({ children, shortcuts, collections, userId, }) {
6
7
  const [open, setOpenState] = useState(false);
7
8
  const [status, setStatus] = useState("idle");
8
9
  const [messages, setMessages] = useState([]);
@@ -11,15 +12,47 @@ export function AssistantStateProvider({ children, shortcuts, collections, }) {
11
12
  const [pendingApproval, setPendingApproval] = useState(null);
12
13
  const [abortController, setAbortController] = useState(null);
13
14
  const [selection, setSelection] = useState(null);
15
+ const [prefill, setPrefill] = useState(null);
14
16
  // Read after mount, so the server render (closed) and the first client render agree.
15
17
  useEffect(() => {
16
18
  if (readStoredOpen())
17
19
  setOpenState(true);
18
20
  }, []);
19
- const setOpen = useCallback((next) => {
21
+ const setOpen = useCallback((next, { remember = true } = {}) => {
20
22
  setOpenState(next);
21
- storeOpen(next);
23
+ if (remember) {
24
+ storeOpen(next);
25
+ }
22
26
  }, []);
27
+ // One conversation per document: switching documents stores the thread and loads the next one.
28
+ // An open proposal belongs to the form it was made for, so it does not follow; an unfinished
29
+ // request is stopped so its answer does not land in another document's thread.
30
+ const collectionSlug = bridge?.collectionSlug;
31
+ const globalSlug = bridge?.globalSlug;
32
+ const documentId = bridge?.documentId;
33
+ const hasBridge = bridge !== null;
34
+ const key = useMemo(() => conversationKey(userId, hasBridge ? { collectionSlug, globalSlug, documentId } : null), [userId, hasBridge, collectionSlug, globalSlug, documentId]);
35
+ const [loadedKey, setLoadedKey] = useState(null);
36
+ const abortRef = useRef(abortController);
37
+ abortRef.current = abortController;
38
+ useEffect(() => {
39
+ if (!key || sameConversation(key, loadedKey)) {
40
+ return;
41
+ }
42
+ abortRef.current?.abort();
43
+ setAbortController(null);
44
+ setStatus("idle");
45
+ setPendingApproval(null);
46
+ if (!carriesOver(loadedKey, key)) {
47
+ setMessages(readConversation(key));
48
+ }
49
+ setLoadedKey(key);
50
+ }, [key, loadedKey]);
51
+ useEffect(() => {
52
+ if (loadedKey && sameConversation(key, loadedKey)) {
53
+ writeConversation(loadedKey, messages);
54
+ }
55
+ }, [key, loadedKey, messages]);
23
56
  const stop = useCallback(() => {
24
57
  abortController?.abort();
25
58
  setAbortController(null);
@@ -42,11 +75,13 @@ export function AssistantStateProvider({ children, shortcuts, collections, }) {
42
75
  formAdapter,
43
76
  pendingApproval,
44
77
  selection,
78
+ prefill,
45
79
  setOpen,
46
80
  setBridge,
47
81
  setFormAdapter,
48
82
  setPendingApproval,
49
83
  setSelection,
84
+ setPrefill,
50
85
  setStatus,
51
86
  setMessages,
52
87
  resetConversation,
@@ -63,6 +98,7 @@ export function AssistantStateProvider({ children, shortcuts, collections, }) {
63
98
  formAdapter,
64
99
  pendingApproval,
65
100
  selection,
101
+ prefill,
66
102
  setOpen,
67
103
  resetConversation,
68
104
  stop,
@@ -0,0 +1,18 @@
1
+ import type { AssistantMessage, DocumentBridgeSnapshot } from "./assistant-context.tsx";
2
+ export type ConversationKey = {
3
+ kind: "stored";
4
+ key: string;
5
+ }
6
+ /** A create view: the thread lives in memory until the document is saved. */
7
+ | {
8
+ kind: "unsaved";
9
+ entity: string;
10
+ };
11
+ export declare function conversationKey(userId: string | number | undefined, bridge: Pick<DocumentBridgeSnapshot, "collectionSlug" | "globalSlug" | "documentId"> | null): ConversationKey | null;
12
+ export declare function sameConversation(a: ConversationKey | null, b: ConversationKey | null): boolean;
13
+ /** Saving a new document: the thread that created it becomes the document's own. */
14
+ export declare function carriesOver(from: ConversationKey | null, to: ConversationKey): boolean;
15
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
16
+ export declare function readConversation(key: ConversationKey): AssistantMessage[];
17
+ export declare function writeConversation(key: ConversationKey, messages: AssistantMessage[]): void;
18
+ export declare function parseStored(raw: string, now: number): AssistantMessage[];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Conversations are kept in the browser, one per user and document, plus one for everything outside
3
+ * a document (lists, dashboard). A document that is not saved yet has none of its own: its thread is
4
+ * carried over to the document once it gets an id.
5
+ */
6
+ const PREFIX = "payload-editor-assistant:chat";
7
+ const MAX_MESSAGES = 50;
8
+ const MAX_AGE_MS = 30 * 24 * 60 * 60_000;
9
+ export function conversationKey(userId, bridge) {
10
+ if (userId === undefined) {
11
+ return null;
12
+ }
13
+ const user = `${PREFIX}:${userId}`;
14
+ if (!bridge || (!bridge.collectionSlug && !bridge.globalSlug)) {
15
+ return { kind: "stored", key: `${user}:general` };
16
+ }
17
+ if (bridge.globalSlug) {
18
+ return { kind: "stored", key: `${user}:global:${bridge.globalSlug}` };
19
+ }
20
+ if (bridge.documentId === undefined || bridge.documentId === null || bridge.documentId === "") {
21
+ return { kind: "unsaved", entity: `collection:${bridge.collectionSlug}` };
22
+ }
23
+ return {
24
+ kind: "stored",
25
+ key: `${user}:collection:${bridge.collectionSlug}:${bridge.documentId}`,
26
+ };
27
+ }
28
+ export function sameConversation(a, b) {
29
+ if (!a || !b) {
30
+ return a === b;
31
+ }
32
+ return a.kind === "stored"
33
+ ? b.kind === "stored" && a.key === b.key
34
+ : b.kind === "unsaved" && a.entity === b.entity;
35
+ }
36
+ /** Saving a new document: the thread that created it becomes the document's own. */
37
+ export function carriesOver(from, to) {
38
+ return (from?.kind === "unsaved" &&
39
+ to.kind === "stored" &&
40
+ to.key.includes(`:${from.entity}:`) &&
41
+ readConversation(to).length === 0);
42
+ }
43
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
44
+ export function readConversation(key) {
45
+ if (key.kind !== "stored") {
46
+ return [];
47
+ }
48
+ try {
49
+ const raw = window.localStorage.getItem(key.key);
50
+ return raw ? parseStored(raw, Date.now()) : [];
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ export function writeConversation(key, messages) {
57
+ if (key.kind !== "stored") {
58
+ return;
59
+ }
60
+ try {
61
+ if (messages.length === 0) {
62
+ window.localStorage.removeItem(key.key);
63
+ return;
64
+ }
65
+ const stored = { savedAt: Date.now(), messages: messages.slice(-MAX_MESSAGES) };
66
+ window.localStorage.setItem(key.key, JSON.stringify(stored));
67
+ }
68
+ catch {
69
+ // Not remembered (full or blocked storage); the thread still works for this page.
70
+ }
71
+ }
72
+ export function parseStored(raw, now) {
73
+ try {
74
+ const stored = JSON.parse(raw);
75
+ if (typeof stored.savedAt !== "number" || now - stored.savedAt > MAX_AGE_MS) {
76
+ return [];
77
+ }
78
+ return Array.isArray(stored.messages)
79
+ ? stored.messages.filter(isMessage).map((message) => ({
80
+ id: message.id,
81
+ role: message.role,
82
+ text: message.text,
83
+ candidates: Array.isArray(message.candidates)
84
+ ? message.candidates.filter(isCandidate)
85
+ : undefined,
86
+ }))
87
+ : [];
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ function isMessage(value) {
94
+ const message = value;
95
+ return (typeof message?.id === "string" &&
96
+ typeof message.text === "string" &&
97
+ (message.role === "user" || message.role === "assistant" || message.role === "system"));
98
+ }
99
+ function isCandidate(value) {
100
+ const candidate = value;
101
+ return typeof candidate?.href === "string" && typeof candidate.label === "string";
102
+ }
@@ -14,6 +14,16 @@ export declare const COPY: {
14
14
  readonly send: "Send";
15
15
  readonly stop: "Stop";
16
16
  readonly working: "Working…";
17
+ readonly steps: {
18
+ readonly thinking: "Thinking…";
19
+ readonly searching: "Searching the content…";
20
+ readonly reading: "Reading a document…";
21
+ readonly reading_form: "Reading the form…";
22
+ readonly proposing: "Preparing the change…";
23
+ readonly drafting: "Preparing the draft…";
24
+ readonly navigating: "Finding the page…";
25
+ readonly writing: "Writing the reply…";
26
+ };
17
27
  readonly selected: "Selected";
18
28
  readonly clearSelection: "Clear selection";
19
29
  readonly approve: "Approve";
@@ -25,6 +35,7 @@ export declare const COPY: {
25
35
  readonly previewHint: "Shown in the form and the preview. Nothing is saved yet.";
26
36
  readonly previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.";
27
37
  readonly undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.";
38
+ readonly refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.";
28
39
  readonly requestFailed: "I could not complete that request.";
29
40
  readonly done: "Done.";
30
41
  readonly proposalFailed: "The proposal could not be applied.";
@@ -65,6 +76,16 @@ export declare const COPY: {
65
76
  readonly send: "Skicka";
66
77
  readonly stop: "Stoppa";
67
78
  readonly working: "Arbetar…";
79
+ readonly steps: {
80
+ readonly thinking: "Tänker…";
81
+ readonly searching: "Söker bland innehållet…";
82
+ readonly reading: "Läser ett dokument…";
83
+ readonly reading_form: "Läser formuläret…";
84
+ readonly proposing: "Förbereder ändringen…";
85
+ readonly drafting: "Förbereder utkastet…";
86
+ readonly navigating: "Letar upp sidan…";
87
+ readonly writing: "Skriver svaret…";
88
+ };
68
89
  readonly selected: "Markerat";
69
90
  readonly clearSelection: "Ta bort markering";
70
91
  readonly approve: "Godkänn";
@@ -76,6 +97,7 @@ export declare const COPY: {
76
97
  readonly previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.";
77
98
  readonly previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.";
78
99
  readonly undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.";
100
+ readonly refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.";
79
101
  readonly requestFailed: "Jag kunde inte slutföra det.";
80
102
  readonly done: "Klart.";
81
103
  readonly proposalFailed: "Förslaget kunde inte genomföras.";
@@ -15,6 +15,16 @@ export const COPY = {
15
15
  send: "Send",
16
16
  stop: "Stop",
17
17
  working: "Working…",
18
+ steps: {
19
+ thinking: "Thinking…",
20
+ searching: "Searching the content…",
21
+ reading: "Reading a document…",
22
+ reading_form: "Reading the form…",
23
+ proposing: "Preparing the change…",
24
+ drafting: "Preparing the draft…",
25
+ navigating: "Finding the page…",
26
+ writing: "Writing the reply…",
27
+ },
18
28
  selected: "Selected",
19
29
  clearSelection: "Clear selection",
20
30
  approve: "Approve",
@@ -26,6 +36,7 @@ export const COPY = {
26
36
  previewHint: "Shown in the form and the preview. Nothing is saved yet.",
27
37
  previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.",
28
38
  undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.",
39
+ refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.",
29
40
  requestFailed: "I could not complete that request.",
30
41
  done: "Done.",
31
42
  proposalFailed: "The proposal could not be applied.",
@@ -66,6 +77,16 @@ export const COPY = {
66
77
  send: "Skicka",
67
78
  stop: "Stoppa",
68
79
  working: "Arbetar…",
80
+ steps: {
81
+ thinking: "Tänker…",
82
+ searching: "Söker bland innehållet…",
83
+ reading: "Läser ett dokument…",
84
+ reading_form: "Läser formuläret…",
85
+ proposing: "Förbereder ändringen…",
86
+ drafting: "Förbereder utkastet…",
87
+ navigating: "Letar upp sidan…",
88
+ writing: "Skriver svaret…",
89
+ },
69
90
  selected: "Markerat",
70
91
  clearSelection: "Ta bort markering",
71
92
  approve: "Godkänn",
@@ -77,6 +98,7 @@ export const COPY = {
77
98
  previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.",
78
99
  previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.",
79
100
  undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.",
101
+ refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.",
80
102
  requestFailed: "Jag kunde inte slutföra det.",
81
103
  done: "Klart.",
82
104
  proposalFailed: "Förslaget kunde inte genomföras.",
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where Admin is too narrow for both, Payload's nav and the panel take turns: whichever the editor
3
+ * opened last stays, and the other comes back when it closes.
4
+ *
5
+ * - Opening the panel closes the nav; closing the panel opens it again.
6
+ * - Opening the nav closes the panel; closing the nav opens it again. Not while the assistant
7
+ * answers or a proposal waits for Keep or Undo: then both stay, so the decision stays in view.
8
+ *
9
+ * Payload's saved nav preference and the editor's own panel choice are not changed.
10
+ */
11
+ export declare function useNavRoom(): void;
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { useNav } from "@payloadcms/ui";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useAssistantState } from "./assistant-context.js";
5
+ /**
6
+ * Payload lays out Admin by the window's width, not knowing the panel takes part of it. It closes
7
+ * its nav at 1440px and below; with the panel open, the space left is that narrow up to 1840px
8
+ * (the panel is `clamp(320px, 22vw, 400px)` wide, see assistant.css). Keep the two in step.
9
+ */
10
+ const LEAVES_NARROW_ADMIN = "(max-width: 1840px)";
11
+ /**
12
+ * Where Admin is too narrow for both, Payload's nav and the panel take turns: whichever the editor
13
+ * opened last stays, and the other comes back when it closes.
14
+ *
15
+ * - Opening the panel closes the nav; closing the panel opens it again.
16
+ * - Opening the nav closes the panel; closing the nav opens it again. Not while the assistant
17
+ * answers or a proposal waits for Keep or Undo: then both stay, so the decision stays in view.
18
+ *
19
+ * Payload's saved nav preference and the editor's own panel choice are not changed.
20
+ */
21
+ export function useNavRoom() {
22
+ const { navOpen, setNavOpen } = useNav();
23
+ const { open, setOpen, status, pendingApproval } = useAssistantState();
24
+ const busy = status === "streaming" || pendingApproval !== null;
25
+ const [narrow, setNarrow] = useState(false);
26
+ const navOpenRef = useRef(navOpen);
27
+ navOpenRef.current = navOpen;
28
+ /** The editor opened the nav with its toggler (not Payload restoring it on load). */
29
+ const editorOpenedNav = useRef(false);
30
+ /** The nav was opened while the panel was busy; leave it open until the panel closes. */
31
+ const navKept = useRef(false);
32
+ const closedNav = useRef(false);
33
+ const closedPanel = useRef(false);
34
+ useEffect(() => {
35
+ const query = window.matchMedia(LEAVES_NARROW_ADMIN);
36
+ const update = () => setNarrow(query.matches);
37
+ update();
38
+ query.addEventListener("change", update);
39
+ return () => query.removeEventListener("change", update);
40
+ }, []);
41
+ useEffect(() => {
42
+ const onClick = (event) => {
43
+ if (event.target instanceof Element &&
44
+ event.target.closest(".nav-toggler") &&
45
+ !navOpenRef.current) {
46
+ editorOpenedNav.current = true;
47
+ }
48
+ };
49
+ document.addEventListener("click", onClick, true);
50
+ return () => document.removeEventListener("click", onClick, true);
51
+ }, []);
52
+ useEffect(() => {
53
+ if (!navOpen) {
54
+ editorOpenedNav.current = false;
55
+ navKept.current = false;
56
+ if (closedPanel.current) {
57
+ closedPanel.current = false;
58
+ setOpen(true, { remember: false });
59
+ }
60
+ return;
61
+ }
62
+ if (!open || !narrow) {
63
+ editorOpenedNav.current = false;
64
+ return;
65
+ }
66
+ if (editorOpenedNav.current) {
67
+ editorOpenedNav.current = false;
68
+ if (busy) {
69
+ navKept.current = true;
70
+ }
71
+ else {
72
+ closedNav.current = false;
73
+ closedPanel.current = true;
74
+ setOpen(false, { remember: false });
75
+ }
76
+ return;
77
+ }
78
+ // The panel opened, or Payload opened the nav from the saved preference after the page loaded.
79
+ if (!navKept.current) {
80
+ closedNav.current = true;
81
+ setNavOpen(false);
82
+ }
83
+ }, [open, narrow, navOpen, busy, setNavOpen, setOpen]);
84
+ useEffect(() => {
85
+ if (open) {
86
+ return;
87
+ }
88
+ if (closedNav.current) {
89
+ setNavOpen(true);
90
+ }
91
+ closedNav.current = false;
92
+ navKept.current = false;
93
+ }, [open, setNavOpen]);
94
+ }
@@ -0,0 +1,22 @@
1
+ import type { PendingApproval } from "./assistant-context.tsx";
2
+ /** One server-side proposal and the form it was made for; keeping it is checked against that form. */
3
+ export type ProposalStep = {
4
+ approvalId: string;
5
+ nonce: string;
6
+ basis: Record<string, unknown>;
7
+ };
8
+ /**
9
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
10
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
11
+ * first step for undo, and every step so each can be kept in order against its own form.
12
+ */
13
+ export declare function refineProposal(open: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
14
+ /** Every step of an applied proposal, oldest first. */
15
+ export declare function proposalSteps(pending: PendingApproval): ProposalStep[];
16
+ /**
17
+ * The same turn produced a second proposal. It was made against the same form as the one it
18
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
19
+ */
20
+ export declare function replaceLatestStep(current: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
21
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
22
+ export declare function isFormProposal(value: unknown): value is PendingApproval;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
3
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
4
+ * first step for undo, and every step so each can be kept in order against its own form.
5
+ */
6
+ export function refineProposal(open, next, basis) {
7
+ if (!open.applied) {
8
+ return next;
9
+ }
10
+ const changed = new Set([
11
+ ...(open.preview?.changedPaths ?? []),
12
+ ...(next.preview?.changedPaths ?? []),
13
+ ]);
14
+ return {
15
+ ...next,
16
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
17
+ earlier: proposalSteps(open),
18
+ applied: { ...open.applied, basis },
19
+ };
20
+ }
21
+ /** Every step of an applied proposal, oldest first. */
22
+ export function proposalSteps(pending) {
23
+ if (!pending.applied) {
24
+ return [];
25
+ }
26
+ return [
27
+ ...(pending.earlier ?? []),
28
+ { approvalId: pending.approvalId, nonce: pending.nonce, basis: pending.applied.basis },
29
+ ];
30
+ }
31
+ /**
32
+ * The same turn produced a second proposal. It was made against the same form as the one it
33
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
34
+ */
35
+ export function replaceLatestStep(current, next, basis) {
36
+ if (!current.applied) {
37
+ return next;
38
+ }
39
+ const changed = new Set([
40
+ ...(current.preview?.changedPaths ?? []),
41
+ ...(next.preview?.changedPaths ?? []),
42
+ ]);
43
+ return {
44
+ ...next,
45
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
46
+ earlier: current.earlier,
47
+ applied: { ...current.applied, basis },
48
+ };
49
+ }
50
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
51
+ export function isFormProposal(value) {
52
+ const pending = value;
53
+ return (pending?.capability === "form.propose" &&
54
+ typeof pending.approvalId === "string" &&
55
+ typeof pending.nonce === "string" &&
56
+ Boolean(pending.preview && typeof pending.preview.data === "object"));
57
+ }