@drawnagency/primitives 0.1.68 → 0.1.70

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.
@@ -2,6 +2,10 @@ export interface ChatMessage {
2
2
  role: "user" | "assistant";
3
3
  content: string;
4
4
  }
5
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
6
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
7
+ * through, and model output is untrusted. Exported for tests. */
8
+ export declare function assistantHtml(md: string): string;
5
9
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
6
10
  export declare function streamChat(body: {
7
11
  messages: ChatMessage[];
@@ -16,7 +20,7 @@ export declare function streamChat(body: {
16
20
  error: string;
17
21
  retryAfterMs?: number;
18
22
  }>;
19
- export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot }: {
23
+ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, variant }: {
20
24
  open: boolean;
21
25
  onClose: () => void;
22
26
  siteName: string;
@@ -25,7 +29,9 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
25
29
  label: string;
26
30
  }[];
27
31
  isEditor?: boolean;
28
- /** Set only by the configurator's test panel. */
32
+ /** Set by the configurator's test panel and the /edit chat FAB. */
29
33
  draftChatbot?: unknown;
34
+ /** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
35
+ variant?: "viewer" | "editor";
30
36
  }): import("react/jsx-runtime").JSX.Element | null;
31
37
  //# sourceMappingURL=ChatPanel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAoB5E,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,EAAE;IAC5F,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,iDAAiD;IACjD,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,kDAyEA"}
1
+ {"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AA6B5E;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE;IAChH,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA6HA"}
@@ -6,6 +6,9 @@ export interface ChatWidgetProps {
6
6
  label: string;
7
7
  }[];
8
8
  isEditor?: boolean;
9
+ /** Editor-only: chat against the current (unsaved) chatbot config. */
10
+ draftChatbot?: unknown;
11
+ variant?: "viewer" | "editor";
9
12
  }
10
- export default function ChatWidget({ siteName, tasks, isEditor }: ChatWidgetProps): import("react/jsx-runtime").JSX.Element;
13
+ export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, variant }: ChatWidgetProps): import("react/jsx-runtime").JSX.Element;
11
14
  //# sourceMappingURL=ChatWidget.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,eAAe,2CAuBhF"}
1
+ {"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CA2ClH"}
@@ -1 +1 @@
1
- {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAyEjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,GAC/B,EAAE,KAAK,2CAm7BP"}
1
+ {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AA0EjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,GAC/B,EAAE,KAAK,2CAi8BP"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -106,6 +106,7 @@
106
106
  "dompurify": "^3.2.6",
107
107
  "jose": "^6.2.2",
108
108
  "lucide-react": "^1.7.0",
109
+ "marked": "^18.0.5",
109
110
  "tailwind-merge": "^3.5.0",
110
111
  "zod": "^4.3.6"
111
112
  },
@@ -1,10 +1,21 @@
1
- import { useRef, useState } from "react";
1
+ import { useEffect, useRef, useState, type MouseEvent } from "react";
2
+ import { marked } from "marked";
2
3
  import { cn } from "../../lib/cn";
4
+ import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
3
5
 
4
6
  export interface ChatMessage { role: "user" | "assistant"; content: string }
5
7
 
6
8
  const SESSION_KEY = "bp-chat-session";
7
9
 
10
+ // Astro's trailingSlash: "ignore" means the viewer can be on "/brand/" while
11
+ // grounding links say "/brand#voice-tone" — the pathnames are equivalent
12
+ // but not literally equal. Per the HTML spec, same-document fragment
13
+ // navigation requires URL equality *excluding* the fragment, so a literal
14
+ // mismatch (e.g. "/brand/" vs "/brand") can't rely on native navigation —
15
+ // this helper only detects that "equivalent but not equal" case so we can
16
+ // handle it ourselves. "/" stays "/" rather than collapsing to "".
17
+ const normalizePathname = (p: string): string => p.replace(/\/+$/, "") || "/";
18
+
8
19
  function sessionId(): string {
9
20
  let id = window.localStorage.getItem(SESSION_KEY);
10
21
  if (!id) {
@@ -21,6 +32,25 @@ const ERROR_COPY: Record<string, string> = {
21
32
  upstream_error: "Something went wrong — please try again.",
22
33
  };
23
34
 
35
+ /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
36
+ * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
37
+ * through, and model output is untrusted. Exported for tests. */
38
+ export function assistantHtml(md: string): string {
39
+ return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
40
+ }
41
+
42
+ // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
43
+ // weight + spacing to clamp to the bubble's text scale.
44
+ const MD_STYLES =
45
+ "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
46
+ "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
47
+ "[&_a]:underline [&_a]:underline-offset-2 " +
48
+ "[&_code]:rounded [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
49
+ "[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-base-200 [&_pre]:p-2 " +
50
+ "[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
51
+ "[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
52
+ "[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
53
+
24
54
  /** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
25
55
  export async function streamChat(
26
56
  body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
@@ -68,14 +98,16 @@ export async function streamChat(
68
98
  }
69
99
  }
70
100
 
71
- export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot }: {
101
+ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: {
72
102
  open: boolean;
73
103
  onClose: () => void;
74
104
  siteName: string;
75
105
  tasks: { id: string; label: string }[];
76
106
  isEditor?: boolean;
77
- /** Set only by the configurator's test panel. */
107
+ /** Set by the configurator's test panel and the /edit chat FAB. */
78
108
  draftChatbot?: unknown;
109
+ /** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
110
+ variant?: "viewer" | "editor";
79
111
  }) {
80
112
  const [messages, setMessages] = useState<ChatMessage[]>([]);
81
113
  const [taskId, setTaskId] = useState<string>("");
@@ -84,6 +116,46 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
84
116
  const [notice, setNotice] = useState<string | null>(null);
85
117
  const streamingRef = useRef("");
86
118
 
119
+ const [mdReady, setMdReady] = useState(false);
120
+ useEffect(() => {
121
+ let alive = true;
122
+ // Failure just leaves mdReady false forever (plain-text messages) — never
123
+ // let it surface as an unhandled rejection.
124
+ void ensureSanitizer().then(() => { if (alive) setMdReady(true); }).catch(() => {});
125
+ return () => { alive = false; };
126
+ }, []);
127
+
128
+ function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
129
+ const link = (e.target as HTMLElement).closest("a");
130
+ if (!link || !link.getAttribute("href")) return;
131
+ const url = new URL(link.href, window.location.href);
132
+ // Native navigation is only safe when there's a hash to scroll to — the
133
+ // grounding emits hashless same-pathname links (e.g. "/") for content
134
+ // before the first heading, and letting those navigate natively would
135
+ // full-reload the page, unmounting the panel and wiping chat history.
136
+ const sameOriginWithHash = variant !== "editor" && url.origin === window.location.origin && url.hash !== "";
137
+ if (sameOriginWithHash) {
138
+ if (url.pathname === window.location.pathname) {
139
+ return; // literally same document: native hash navigation, panel stays open
140
+ }
141
+ if (normalizePathname(url.pathname) === normalizePathname(window.location.pathname)) {
142
+ // Equivalent pathnames (trailing-slash only difference) but NOT
143
+ // literally equal — per spec, same-document fragment navigation
144
+ // requires URL equality excluding the fragment, so the browser would
145
+ // treat a literal mismatch as a full cross-document navigation,
146
+ // unmounting the panel and wiping chat history. Perform the fragment
147
+ // navigation ourselves on the current (already-correct) URL instead.
148
+ e.preventDefault();
149
+ window.location.hash = url.hash;
150
+ return;
151
+ }
152
+ }
153
+ // Cross-page (or same-page but hashless, or any /edit click): new tab so
154
+ // chat history / unsaved editor state survive.
155
+ e.preventDefault();
156
+ window.open(url.href, "_blank", "noopener");
157
+ }
158
+
87
159
  async function send() {
88
160
  const text = input.trim();
89
161
  if (!text || busy) return;
@@ -114,7 +186,11 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
114
186
 
115
187
  return (
116
188
  <div role="dialog" aria-label={`Chat with ${siteName}`}
117
- className="fixed bottom-20 right-4 z-40 flex h-[28rem] w-80 flex-col rounded-lg border border-base-200 bg-base shadow-xl">
189
+ className={cn(
190
+ "fixed z-40 flex h-[28rem] w-80 flex-col rounded-lg border border-base-200 bg-base shadow-xl",
191
+ variant === "editor" ? "bottom-40 right-4 lg:right-auto fab-container-right" : "bottom-20 right-4",
192
+ )}>
193
+
118
194
  <div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
119
195
  <span className="text-sm font-medium text-base-contrast">{siteName} assistant</span>
120
196
  {tasks.length > 0 && (
@@ -126,13 +202,21 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
126
202
  )}
127
203
  <button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
128
204
  </div>
129
- <div className="flex-1 space-y-2 overflow-y-auto p-3">
130
- {messages.map((m, i) => (
131
- <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
132
- m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
133
- {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
134
- </div>
135
- ))}
205
+ <div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
206
+ {messages.map((m, i) =>
207
+ m.role === "assistant" && mdReady && m.content ? (
208
+ <div
209
+ key={i}
210
+ className={cn("max-w-[85%] rounded-lg bg-base-accent px-3 py-2 text-sm text-base-contrast", MD_STYLES)}
211
+ dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
212
+ />
213
+ ) : (
214
+ <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
215
+ m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
216
+ {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
217
+ </div>
218
+ ),
219
+ )}
136
220
  {notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
137
221
  </div>
138
222
  <form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
@@ -1,7 +1,14 @@
1
- // Viewer-facing chat launcher. Renders ONLY the floating button until first
2
- // open; the panel (and its code) loads lazily so viewers who never click pay
3
- // almost nothing. Browser-safe: no node imports, no process.env.
1
+ // Chat launcher FAB. Renders ONLY the floating button until first open; the
2
+ // panel (and its code) loads lazily so viewers who never click pay almost
3
+ // nothing. Browser-safe: no node imports, no process.env.
4
+ //
5
+ // Two placements:
6
+ // - "viewer" (default): bottom-right corner of viewer pages.
7
+ // - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
8
+ // bottom-16, bug report at bottom-28 above it), aligned to the content edge
9
+ // via fab-container-right like its neighbors.
4
10
  import { lazy, Suspense, useState } from "react";
11
+ import { cn } from "../../lib/cn";
5
12
 
6
13
  const ChatPanel = lazy(() => import("./ChatPanel"));
7
14
 
@@ -10,9 +17,12 @@ export interface ChatWidgetProps {
10
17
  /** Already audience-filtered server-side — never pass unfiltered config tasks. */
11
18
  tasks: { id: string; label: string }[];
12
19
  isEditor?: boolean;
20
+ /** Editor-only: chat against the current (unsaved) chatbot config. */
21
+ draftChatbot?: unknown;
22
+ variant?: "viewer" | "editor";
13
23
  }
14
24
 
15
- export default function ChatWidget({ siteName, tasks, isEditor }: ChatWidgetProps) {
25
+ export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, variant = "viewer" }: ChatWidgetProps) {
16
26
  const [open, setOpen] = useState(false);
17
27
  const [everOpened, setEverOpened] = useState(false);
18
28
 
@@ -20,17 +30,37 @@ export default function ChatWidget({ siteName, tasks, isEditor }: ChatWidgetProp
20
30
  <>
21
31
  <button
22
32
  type="button"
23
- aria-label={`Chat with ${siteName}`}
33
+ aria-label={open ? "Collapse chat" : `Chat with ${siteName}`}
34
+ aria-expanded={open}
24
35
  onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
25
- className="fixed bottom-4 right-4 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity"
36
+ className={cn(
37
+ "fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
38
+ variant === "editor"
39
+ ? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
40
+ : "bottom-4 right-4 h-12 w-12",
41
+ )}
26
42
  >
27
- <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
28
- <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
29
- </svg>
43
+ {open ? (
44
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
45
+ <path d="m6 9 6 6 6-6" />
46
+ </svg>
47
+ ) : (
48
+ <svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
50
+ </svg>
51
+ )}
30
52
  </button>
31
53
  {everOpened && (
32
54
  <Suspense fallback={null}>
33
- <ChatPanel open={open} onClose={() => setOpen(false)} siteName={siteName} tasks={tasks} isEditor={isEditor} />
55
+ <ChatPanel
56
+ open={open}
57
+ onClose={() => setOpen(false)}
58
+ siteName={siteName}
59
+ tasks={tasks}
60
+ isEditor={isEditor}
61
+ draftChatbot={draftChatbot}
62
+ variant={variant}
63
+ />
34
64
  </Suspense>
35
65
  )}
36
66
  </>
@@ -190,7 +190,7 @@ export function BugReportFAB() {
190
190
  type="button"
191
191
  onClick={handleOpen}
192
192
  aria-label="Report a bug"
193
- className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
193
+ className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
194
194
  >
195
195
  {/* Bug / beetle SVG icon */}
196
196
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -20,6 +20,7 @@ import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
20
20
  import { EditorModal } from "./EditorModal";
21
21
  import { SiteSettingsModal } from "./SiteSettingsModal";
22
22
  import { ChatbotConfigModal } from "./ChatbotConfigModal";
23
+ import ChatWidget from "../chat/ChatWidget";
23
24
  import { MediaLibraryModal } from "./MediaLibraryModal";
24
25
  import { MediaLibraryContext } from "./MediaLibraryContext";
25
26
  import { ProcessingIndicator } from "./ProcessingIndicator";
@@ -902,6 +903,20 @@ export default function EditorShell({
902
903
  the button would always 401 — hide it there. */}
903
904
  {!capabilities.passwordOnly && <BugReportFAB />}
904
905
 
906
+ {/* Editor chat FAB: chat while editing, against the DRAFT chatbot
907
+ config (tasks + prompt reflect unsaved changes; grounding is
908
+ published content). Gated only on the platform switch — editors
909
+ may chat before the widget is enabled for viewers. */}
910
+ {chatbotPlatformEnabled && siteConfig && (
911
+ <ChatWidget
912
+ siteName={siteConfig.siteName}
913
+ tasks={siteConfig.chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
914
+ isEditor
915
+ draftChatbot={siteConfig.chatbot}
916
+ variant="editor"
917
+ />
918
+ )}
919
+
905
920
  {rejectedUploads.length > 0 && (
906
921
  <div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
907
922
  <UploadRejectionAlert
@@ -1681,7 +1696,7 @@ function EditorToolbar({
1681
1696
  {!historyState && (
1682
1697
  <button
1683
1698
  onClick={toggleEditMode}
1684
- className="cursor-pointer fixed bottom-4 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1699
+ className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
1685
1700
  aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
1686
1701
  >
1687
1702
  {isEditMode ? (