@drawnagency/primitives 0.1.88 → 0.1.89

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 (66) hide show
  1. package/dist/{chunk-PTVY3Q7P.js → chunk-2QXJ2KGB.js} +1 -1
  2. package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
  3. package/dist/{chunk-NEDOJHHR.js → chunk-J75XGXYD.js} +2 -2
  4. package/dist/{chunk-SK6GGTTO.js → chunk-L2MAJKKC.js} +3 -3
  5. package/dist/{chunk-H2FENAQH.js → chunk-MVX4ULYE.js} +6 -6
  6. package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
  7. package/dist/{chunk-XDG2K6UT.js → chunk-QFV5TSPO.js} +1 -1
  8. package/dist/{chunk-OEL57NYV.js → chunk-VK5KNH73.js} +25 -1
  9. package/dist/{chunk-XPUBUR64.js → chunk-WCBYXNAG.js} +27 -2
  10. package/dist/{chunk-WA3HFN54.js → chunk-Y24T75HT.js} +1 -1
  11. package/dist/components/chat/ChatPanel.d.ts +20 -3
  12. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  13. package/dist/components/chat/ChatWidget.d.ts +7 -1
  14. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  15. package/dist/components/chat/chat-history.d.ts +24 -0
  16. package/dist/components/chat/chat-history.d.ts.map +1 -0
  17. package/dist/components/editor/index.js +4 -4
  18. package/dist/components/primitives/index.js +5 -5
  19. package/dist/components/sections/brand-guide.js +5 -5
  20. package/dist/components/sections/register-schemas.js +8 -8
  21. package/dist/components/sections/register.js +8 -8
  22. package/dist/components/shared/Navigation.d.ts +6 -1
  23. package/dist/components/shared/Navigation.d.ts.map +1 -1
  24. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  25. package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
  26. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  27. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  28. package/dist/hooks/useMediaPipeline.d.ts +5 -1
  29. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  30. package/dist/index.js +14 -4
  31. package/dist/lib/chatbot.d.ts +10 -0
  32. package/dist/lib/chatbot.d.ts.map +1 -0
  33. package/dist/lib/contrast.d.ts +5 -0
  34. package/dist/lib/contrast.d.ts.map +1 -1
  35. package/dist/lib/index.d.ts +2 -1
  36. package/dist/lib/index.d.ts.map +1 -1
  37. package/dist/lib/index.js +10 -2
  38. package/dist/media/compose-preview.d.ts +23 -0
  39. package/dist/media/compose-preview.d.ts.map +1 -0
  40. package/dist/media/index.d.ts +1 -0
  41. package/dist/media/index.d.ts.map +1 -1
  42. package/dist/media/index.js +4 -2
  43. package/dist/media/preview-inputs.d.ts +31 -0
  44. package/dist/media/preview-inputs.d.ts.map +1 -0
  45. package/dist/schemas/index.js +2 -2
  46. package/dist/schemas/media.d.ts +2 -0
  47. package/dist/schemas/media.d.ts.map +1 -1
  48. package/dist/schemas/site-config.d.ts +12 -0
  49. package/dist/schemas/site-config.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/chat/ChatPanel.tsx +437 -65
  52. package/src/components/chat/ChatWidget.tsx +90 -32
  53. package/src/components/chat/chat-history.ts +105 -0
  54. package/src/components/shared/Navigation.tsx +18 -5
  55. package/src/components/shell/EditorShell.tsx +71 -1
  56. package/src/components/shell/MediaLibraryContext.tsx +1 -1
  57. package/src/components/shell/SiteSettingsChatbot.tsx +47 -0
  58. package/src/hooks/useMediaPipeline.ts +7 -3
  59. package/src/lib/chatbot.ts +30 -0
  60. package/src/lib/contrast.ts +12 -0
  61. package/src/lib/index.ts +2 -1
  62. package/src/media/compose-preview.ts +90 -0
  63. package/src/media/index.ts +1 -0
  64. package/src/media/preview-inputs.ts +77 -0
  65. package/src/schemas/media.ts +3 -0
  66. package/src/schemas/site-config.ts +26 -1
@@ -1,14 +1,30 @@
1
1
  import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
2
2
  import { marked } from "marked";
3
+ import { ArrowDown, ChevronDown, ChevronRight, History, Pin, PinOff, SquarePen, Square, Trash2 } from "lucide-react";
3
4
  import { cn } from "../../lib/cn";
4
5
  import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
6
+ import { resolveThinkingPhrases } from "../../lib/chatbot";
7
+ import { loadHistory, upsertConversation, setConversationPinned, removeConversation, type StoredConversation } from "./chat-history";
5
8
  import { Select } from "../shared/Select";
6
9
  import { IconButton } from "../shared/IconButton";
7
10
  import { RemoveIcon } from "../shared/icons";
8
11
 
9
12
  export interface ChatMessage { role: "user" | "assistant"; content: string }
10
13
 
14
+ /** How the conversation is framed on screen:
15
+ * - "floating": the compact anchored panel (/edit FAB + settings test chat)
16
+ * - "docked": full-height right sidebar (viewer, >=768px)
17
+ * - "sheet": bottom sheet (viewer, phones) */
18
+ export type ChatPanelMode = "floating" | "docked" | "sheet";
19
+
11
20
  const SESSION_KEY = "bp-chat-session";
21
+ const MESSAGES_KEY = "bp-chat-messages";
22
+ const CONVO_ID_KEY = "bp-chat-convo-id";
23
+ const STORED_MESSAGE_CAP = 40;
24
+ /** Distance (px) from the bottom of the transcript within which auto-scroll
25
+ * stays engaged. Generous on purpose: a single wheel tick travels ~100px and
26
+ * must not silently disengage the pin. */
27
+ const PIN_THRESHOLD = 160;
12
28
 
13
29
  function sessionId(): string {
14
30
  let id = window.localStorage.getItem(SESSION_KEY);
@@ -19,12 +35,31 @@ function sessionId(): string {
19
35
  return id;
20
36
  }
21
37
 
38
+ /** Conversation persistence (viewer modes only): internal links open in the
39
+ * same tab, so the transcript must survive page navigation. sessionStorage —
40
+ * per-tab, cleared when the visit ends. */
41
+ function loadStoredMessages(): ChatMessage[] {
42
+ try {
43
+ const parsed: unknown = JSON.parse(window.sessionStorage.getItem(MESSAGES_KEY) ?? "[]");
44
+ if (!Array.isArray(parsed)) return [];
45
+ return parsed
46
+ .filter((m): m is ChatMessage =>
47
+ !!m && typeof m === "object" &&
48
+ ((m as ChatMessage).role === "user" || (m as ChatMessage).role === "assistant") &&
49
+ typeof (m as ChatMessage).content === "string")
50
+ .slice(-STORED_MESSAGE_CAP);
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
22
56
  const ERROR_COPY: Record<string, string> = {
23
57
  rate_limited: "You've hit the message limit — try again in a minute.",
24
58
  disabled: "Chat isn't available right now.",
25
59
  not_authorized: "You don't have access to chat on this site.",
26
60
  upstream_error: "Something went wrong — please try again.",
27
61
  };
62
+ const RETRYABLE = new Set(["rate_limited", "upstream_error"]);
28
63
 
29
64
  /** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
30
65
  * having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
@@ -34,7 +69,7 @@ export function assistantHtml(md: string): string {
34
69
  }
35
70
 
36
71
  // Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
37
- // weight + spacing to clamp to the bubble's text scale.
72
+ // weight + spacing to clamp to the message's text scale.
38
73
  const MD_STYLES =
39
74
  "[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
40
75
  "[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
@@ -49,12 +84,14 @@ const MD_STYLES =
49
84
  export async function streamChat(
50
85
  body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown; simulateAudience?: string },
51
86
  onText: (text: string) => void,
87
+ signal?: AbortSignal,
52
88
  ): Promise<{ ok: true; truncated: boolean } | { ok: false; error: string; retryAfterMs?: number }> {
53
89
  try {
54
90
  const res = await fetch("/api/chat", {
55
91
  method: "POST",
56
92
  headers: { "Content-Type": "application/json" },
57
93
  body: JSON.stringify(body),
94
+ signal,
58
95
  });
59
96
  if (!res.ok || !res.body) {
60
97
  const err = await res.json().catch(() => ({ error: "upstream_error" }));
@@ -86,32 +123,72 @@ export async function streamChat(
86
123
  }
87
124
  return errored ? { ok: false, error: errored } : { ok: true, truncated };
88
125
  } catch {
126
+ // A user-initiated stop is a clean finish, not a failure — whatever text
127
+ // already streamed stays in the transcript.
128
+ if (signal?.aborted) return { ok: true, truncated: false };
89
129
  // Network failure or a mid-stream reader rejection (upstream abort) — never
90
130
  // let this propagate out of send(), or `busy` sticks true forever.
91
131
  return { ok: false, error: "upstream_error" };
92
132
  }
93
133
  }
94
134
 
95
- export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer" }: {
135
+ export default function ChatPanel({ open, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
96
136
  open: boolean;
97
137
  onClose: () => void;
98
138
  siteName: string;
139
+ /** Resolved assistant display name (per-audience, server-side). */
140
+ botName: string;
141
+ /** Raw configured phrases; "…" appending + fallback happen here. */
142
+ thinkingPhrases?: string[];
99
143
  tasks: { id: string; label: string }[];
100
144
  isEditor?: boolean;
101
145
  /** Set by the configurator's test panel and the /edit chat FAB. */
102
146
  draftChatbot?: unknown;
103
147
  /** Configurator test panel only: preview this audience's prompt overlay. */
104
148
  simulateAudience?: string;
105
- /** Anchors the panel above its launcher: viewer corner FAB vs /edit FAB column. */
149
+ /** Floating mode only — anchors the panel above its launcher: viewer corner
150
+ * FAB vs /edit FAB column. */
106
151
  variant?: "viewer" | "editor";
152
+ mode?: ChatPanelMode;
153
+ /** Focus the composer when opened (explicit user open on desktop). Never
154
+ * used for the sheet — autofocusing there pops the keyboard over content. */
155
+ focusComposer?: boolean;
156
+ /** Viewer's audience (null for editor sessions) — tags history entries so
157
+ * the list never shows another audience's transcripts. */
158
+ audience?: string | null;
107
159
  }) {
108
- const [messages, setMessages] = useState<ChatMessage[]>([]);
160
+ const persistent = mode !== "floating";
161
+ const [messages, setMessages] = useState<ChatMessage[]>(() => (persistent ? loadStoredMessages() : []));
109
162
  const [taskId, setTaskId] = useState<string>("");
110
163
  const [input, setInput] = useState("");
111
164
  const [busy, setBusy] = useState(false);
112
165
  const [notice, setNotice] = useState<string | null>(null);
166
+ const [canRetry, setCanRetry] = useState(false);
167
+ const [thinking, setThinking] = useState("");
168
+ const [jumpVisible, setJumpVisible] = useState(false);
169
+ const [showHistory, setShowHistory] = useState(false);
170
+ const [history, setHistory] = useState<StoredConversation[]>([]);
113
171
  const streamingRef = useRef("");
172
+ const convoIdRef = useRef<string | null>(null);
173
+ const historyAudience = audience ?? "editor";
174
+
175
+ // Stable per-conversation id, shared with sessionStorage so a same-tab
176
+ // navigation keeps appending to the same history entry.
177
+ function ensureConvoId(): string {
178
+ if (!convoIdRef.current) {
179
+ try { convoIdRef.current = window.sessionStorage.getItem(CONVO_ID_KEY); } catch {}
180
+ if (!convoIdRef.current) {
181
+ convoIdRef.current = crypto.randomUUID();
182
+ try { window.sessionStorage.setItem(CONVO_ID_KEY, convoIdRef.current); } catch {}
183
+ }
184
+ }
185
+ return convoIdRef.current;
186
+ }
187
+ const abortRef = useRef<AbortController | null>(null);
114
188
  const composerRef = useRef<HTMLTextAreaElement>(null);
189
+ const panelRef = useRef<HTMLDivElement>(null);
190
+ const listRef = useRef<HTMLDivElement>(null);
191
+ const pinnedRef = useRef(true);
115
192
 
116
193
  // Autosize the composer: height tracks content up to the className's
117
194
  // 4-line max-h cap, then it scrolls internally. Keyed on `input` so the
@@ -148,6 +225,84 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
148
225
  return () => { alive = false; };
149
226
  }, []);
150
227
 
228
+ // Persist the transcript across same-tab navigations (see loadStoredMessages).
229
+ // The mid-stream assistant stub is empty — don't store it. The history
230
+ // archive updates only between responses (`busy` in deps: the flip to false
231
+ // after a stream lands the final transcript) — not on every token.
232
+ useEffect(() => {
233
+ if (!persistent) return;
234
+ const stored = messages.filter((m) => m.content !== "").slice(-STORED_MESSAGE_CAP);
235
+ try {
236
+ window.sessionStorage.setItem(MESSAGES_KEY, JSON.stringify(stored));
237
+ } catch { /* storage unavailable — transcript just won't survive navigation */ }
238
+ if (!busy && stored.length > 0) {
239
+ upsertConversation({ id: ensureConvoId(), audience: historyAudience, messages: stored });
240
+ }
241
+ }, [messages, busy, persistent, historyAudience]);
242
+
243
+ // Stick-to-bottom: keep the newest tokens in view ONLY while the reader is
244
+ // already near the bottom; the moment they scroll up to read, stop moving
245
+ // them. They re-engage by scrolling back down or via the jump pill.
246
+ useEffect(() => {
247
+ const el = listRef.current;
248
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
249
+ }, [messages, thinking]);
250
+
251
+ function onListScroll() {
252
+ const el = listRef.current;
253
+ if (!el) return;
254
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
255
+ const pinned = distance < PIN_THRESHOLD;
256
+ pinnedRef.current = pinned;
257
+ setJumpVisible(!pinned);
258
+ }
259
+
260
+ function jumpToLatest() {
261
+ const el = listRef.current;
262
+ if (!el) return;
263
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
264
+ el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
265
+ pinnedRef.current = true;
266
+ setJumpVisible(false);
267
+ }
268
+
269
+ // Sheet mode on iOS: the keyboard shrinks visualViewport while fixed
270
+ // elements stay put behind it. Track the keyboard as --kb-offset (raises
271
+ // the sheet) and the visual height as --vvh (caps the sheet's height).
272
+ useEffect(() => {
273
+ if (mode !== "sheet" || !open) return;
274
+ const vv = window.visualViewport;
275
+ const el = panelRef.current;
276
+ if (!vv || !el) return;
277
+ const update = () => {
278
+ el.style.setProperty("--vvh", `${vv.height}px`);
279
+ el.style.setProperty("--kb-offset", `${Math.max(0, window.innerHeight - vv.height - vv.offsetTop)}px`);
280
+ };
281
+ update();
282
+ vv.addEventListener("resize", update);
283
+ vv.addEventListener("scroll", update);
284
+ return () => {
285
+ vv.removeEventListener("resize", update);
286
+ vv.removeEventListener("scroll", update);
287
+ };
288
+ }, [mode, open]);
289
+
290
+ // The open sheet is modal: the page behind it must not scroll.
291
+ useEffect(() => {
292
+ if (mode !== "sheet" || !open) return;
293
+ const prev = document.body.style.overflow;
294
+ document.body.style.overflow = "hidden";
295
+ return () => { document.body.style.overflow = prev; };
296
+ }, [mode, open]);
297
+
298
+ // Explicit-open focus: composer on desktop, the panel itself on the sheet
299
+ // (keyboard-pop on open is hostile on phones).
300
+ useEffect(() => {
301
+ if (!open) return;
302
+ if (mode === "sheet") panelRef.current?.focus();
303
+ else if (focusComposer) composerRef.current?.focus();
304
+ }, [open, mode, focusComposer]);
305
+
151
306
  function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
152
307
  const link = (e.target as HTMLElement).closest("a");
153
308
  if (!link || !link.getAttribute("href")) return;
@@ -157,7 +312,7 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
157
312
  // #anchor but improvises the path around it (a bare "#anchor", or a path
158
313
  // copied from elsewhere in the prompt). If the cited anchor exists in the
159
314
  // CURRENT document — viewer page or /edit editor, both render heading
160
- // ids — scroll to it in place; nothing navigates and the panel survives.
315
+ // ids — scroll to it in place.
161
316
  let anchorId = "";
162
317
  if (url.hash.length > 1 && url.origin === window.location.origin) {
163
318
  try {
@@ -168,25 +323,38 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
168
323
  }
169
324
  const target = anchorId ? document.getElementById(anchorId) : null;
170
325
  if (target) {
326
+ // Phone convention: the sheet covers the page, so following a citation
327
+ // collapses it to reveal the section. The docked sidebar stays open.
328
+ if (mode === "sheet") onClose();
171
329
  target.scrollIntoView({ behavior: "smooth", block: "start" });
172
330
  window.history.replaceState(null, "", url.hash);
173
331
  return;
174
332
  }
175
- // Anchor not in this document (cross-page citation on a multi-page
176
- // portal, external URL, hashless link, or hallucinated anchor): new tab
177
- // so chat history and any unsaved editor state survive.
333
+ if (url.origin === window.location.origin) {
334
+ if (persistent) {
335
+ // Internal cross-page citation, viewer chat: navigate in place — the
336
+ // sessionStorage transcript (and docked-open state) survive the load.
337
+ window.location.assign(url.href);
338
+ return;
339
+ }
340
+ // Editor surfaces (floating mode): same-tab navigation would destroy
341
+ // unsaved editor state — keep internal links in a new tab there.
342
+ window.open(url.href, "_blank", "noopener");
343
+ return;
344
+ }
178
345
  window.open(url.href, "_blank", "noopener");
179
346
  }
180
347
 
181
- async function send() {
182
- const text = input.trim();
183
- if (!text || busy) return;
348
+ async function run(history: ChatMessage[]) {
184
349
  setNotice(null);
185
- setInput("");
350
+ setCanRetry(false);
186
351
  setBusy(true);
187
- const history: ChatMessage[] = [...messages, { role: "user", content: text }];
352
+ const phrases = resolveThinkingPhrases(thinkingPhrases);
353
+ setThinking(phrases[Math.floor(Math.random() * phrases.length)]);
188
354
  setMessages([...history, { role: "assistant", content: "" }]);
189
355
  streamingRef.current = "";
356
+ const controller = new AbortController();
357
+ abortRef.current = controller;
190
358
  const result = await streamChat(
191
359
  { messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
192
360
  (delta) => {
@@ -194,72 +362,276 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
194
362
  const streamed = streamingRef.current;
195
363
  setMessages([...history, { role: "assistant", content: streamed }]);
196
364
  },
365
+ controller.signal,
197
366
  );
367
+ abortRef.current = null;
198
368
  setBusy(false);
199
369
  if (!result.ok) {
200
370
  setMessages(history); // drop the empty assistant stub
201
371
  setNotice(ERROR_COPY[result.error] ?? ERROR_COPY.upstream_error);
202
- } else if (result.truncated) {
203
- setNotice("Some brand content didn't fit — answers may be incomplete.");
372
+ setCanRetry(RETRYABLE.has(result.error));
373
+ } else {
374
+ if (!streamingRef.current) setMessages(history); // stopped before the first token
375
+ if (result.truncated) setNotice("Some brand content didn't fit — answers may be incomplete.");
204
376
  }
205
377
  }
206
378
 
379
+ async function send(textOverride?: string) {
380
+ const text = (textOverride ?? input).trim();
381
+ if (!text || busy) return;
382
+ if (textOverride === undefined) setInput("");
383
+ await run([...messages, { role: "user", content: text }]);
384
+ }
385
+
386
+ function retry() {
387
+ if (busy || messages.length === 0 || messages[messages.length - 1].role !== "user") return;
388
+ void run(messages);
389
+ }
390
+
391
+ function stop() {
392
+ abortRef.current?.abort();
393
+ }
394
+
395
+ // Starts fresh; the previous conversation stays in the history archive.
396
+ function clearConversation() {
397
+ if (busy) stop();
398
+ setMessages([]);
399
+ setNotice(null);
400
+ setCanRetry(false);
401
+ setShowHistory(false);
402
+ convoIdRef.current = null;
403
+ try {
404
+ window.sessionStorage.removeItem(MESSAGES_KEY);
405
+ window.sessionStorage.removeItem(CONVO_ID_KEY);
406
+ } catch {}
407
+ composerRef.current?.focus();
408
+ }
409
+
410
+ function openHistory() {
411
+ setHistory(loadHistory(historyAudience));
412
+ setShowHistory(true);
413
+ }
414
+
415
+ function restoreConversation(c: StoredConversation) {
416
+ if (busy) stop();
417
+ convoIdRef.current = c.id;
418
+ try { window.sessionStorage.setItem(CONVO_ID_KEY, c.id); } catch {}
419
+ setMessages(c.messages);
420
+ setNotice(null);
421
+ setCanRetry(false);
422
+ setShowHistory(false);
423
+ }
424
+
425
+ function togglePinned(c: StoredConversation) {
426
+ setConversationPinned(c.id, !c.pinned);
427
+ setHistory(loadHistory(historyAudience));
428
+ }
429
+
430
+ function deleteFromHistory(c: StoredConversation) {
431
+ removeConversation(c.id);
432
+ // Deleting the ACTIVE conversation also clears the live transcript —
433
+ // otherwise the next message would silently resurrect the entry.
434
+ if (c.id === convoIdRef.current) {
435
+ setMessages([]);
436
+ convoIdRef.current = null;
437
+ try {
438
+ window.sessionStorage.removeItem(MESSAGES_KEY);
439
+ window.sessionStorage.removeItem(CONVO_ID_KEY);
440
+ } catch {}
441
+ }
442
+ setHistory(loadHistory(historyAudience));
443
+ }
444
+
207
445
  if (!open) return null;
208
446
 
447
+ const lastIndex = messages.length - 1;
448
+
209
449
  return (
210
- <div role="dialog" aria-label={`Chat with ${siteName}`}
211
- className={cn(
212
- "fixed z-40 flex h-[28rem] w-80 flex-col rounded-outer border border-base-200 bg-base shadow-xl",
213
- variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
214
- )}>
215
-
216
- <div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
217
- <span className="text-sm font-medium text-base-contrast">Portal Assistant</span>
218
- <IconButton icon={<RemoveIcon size={18} />} label="Close chat" onClick={onClose} className="rounded-inner hover:bg-base-accent" />
219
- </div>
220
- {tasks.length > 0 && (
221
- <div className="border-b border-base-200 px-3 py-2">
222
- <Select
223
- label="Task"
224
- value={taskId}
225
- onChange={setTaskId}
226
- options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
227
- selectClassName="py-1"
228
- />
229
- </div>
450
+ <>
451
+ {mode === "sheet" && (
452
+ <div className="fixed inset-0 z-50 bg-black/50" onClick={onClose} aria-hidden="true" />
230
453
  )}
231
- <div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
232
- {messages.map((m, i) =>
233
- m.role === "assistant" && mdReady && m.content ? (
234
- <div
235
- key={i}
236
- className={cn("max-w-[85%] rounded-outer bg-base-accent px-3 py-2 text-sm text-base-contrast", MD_STYLES)}
237
- dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
238
- />
239
- ) : (
240
- <div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-outer px-3 py-2 text-sm",
241
- m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
242
- {m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
243
- </div>
454
+ <div
455
+ ref={panelRef}
456
+ role={mode === "docked" ? "complementary" : "dialog"}
457
+ aria-modal={mode === "sheet" ? true : undefined}
458
+ aria-label={botName}
459
+ tabIndex={-1}
460
+ onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
461
+ className={cn(
462
+ "flex flex-col bg-base focus:outline-none",
463
+ mode === "floating" && cn(
464
+ "fixed z-40 h-[28rem] w-80 rounded-outer border border-base-200 shadow-xl",
465
+ variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
244
466
  ),
467
+ mode === "docked" && "fixed right-0 bottom-0 top-16 xl:top-0 z-40 w-[var(--chat-w)] border-l border-base-200",
468
+ mode === "sheet" && "fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
245
469
  )}
246
- {notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
470
+ style={mode === "sheet" ? { bottom: "var(--kb-offset, 0px)", maxHeight: "calc(var(--vvh, 100dvh) - 2.5rem)" } : undefined}
471
+ >
472
+ <div className={cn(
473
+ "flex shrink-0 items-center justify-between gap-2 border-b border-base-200 px-3",
474
+ mode === "docked" ? "h-16" : "py-2",
475
+ )}>
476
+ <span className="text-sm font-medium text-base-contrast">{botName}</span>
477
+ <div className="flex items-center gap-1">
478
+ {persistent && (
479
+ <IconButton icon={<History size={16} />} label="Chat history"
480
+ onClick={() => (showHistory ? setShowHistory(false) : openHistory())}
481
+ className="rounded-inner hover:bg-base-accent" />
482
+ )}
483
+ {messages.length > 0 && (
484
+ <IconButton icon={<SquarePen size={16} />} label="New conversation" onClick={clearConversation} className="rounded-inner hover:bg-base-accent" />
485
+ )}
486
+ <IconButton
487
+ icon={mode === "docked" ? <ChevronRight size={18} /> : mode === "sheet" ? <ChevronDown size={18} /> : <RemoveIcon size={18} />}
488
+ label={mode === "docked" ? "Hide chat" : "Close chat"}
489
+ onClick={onClose}
490
+ className="rounded-inner hover:bg-base-accent"
491
+ />
492
+ </div>
493
+ </div>
494
+ {tasks.length > 0 && (
495
+ <div className="shrink-0 border-b border-base-200 px-3 py-2">
496
+ <Select
497
+ label="Task"
498
+ value={taskId}
499
+ onChange={setTaskId}
500
+ options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
501
+ selectClassName="py-1"
502
+ />
503
+ </div>
504
+ )}
505
+ <div className="relative min-h-0 flex-1">
506
+ <div
507
+ ref={listRef}
508
+ role="log"
509
+ aria-label="Conversation"
510
+ onScroll={onListScroll}
511
+ onClick={onMessagesClick}
512
+ className="h-full space-y-3 overflow-y-auto p-3"
513
+ >
514
+ {messages.length === 0 && (
515
+ <div className="flex h-full flex-col justify-end gap-2 pb-1">
516
+ <p className="text-sm text-base-contrast-light">
517
+ Ask about {siteName} — colors, voice, logo usage, anything in the brand guide.
518
+ </p>
519
+ <div className="mt-1 flex flex-wrap gap-1.5">
520
+ {tasks.slice(0, 4).map((t) => (
521
+ <button key={t.id} type="button"
522
+ onClick={() => { setTaskId(t.id); composerRef.current?.focus(); }}
523
+ className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary">
524
+ {t.label}
525
+ </button>
526
+ ))}
527
+ {tasks.length === 0 && ["What are our primary brand colors?", "How should the logo be used?"].map((s) => (
528
+ <button key={s} type="button" onClick={() => void send(s)}
529
+ className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary">
530
+ {s}
531
+ </button>
532
+ ))}
533
+ </div>
534
+ </div>
535
+ )}
536
+ {messages.map((m, i) =>
537
+ m.role === "user" ? (
538
+ <div key={i} className="ml-auto max-w-[85%] whitespace-pre-wrap rounded-outer bg-primary px-3 py-2 text-sm text-primary-contrast">
539
+ {m.content}
540
+ </div>
541
+ ) : m.content === "" ? (
542
+ busy && i === lastIndex ? (
543
+ <div key={i} className="text-sm text-base-contrast-light motion-safe:animate-pulse">{thinking}</div>
544
+ ) : null
545
+ ) : mdReady ? (
546
+ <div
547
+ key={i}
548
+ className={cn("text-sm leading-relaxed text-base-contrast", MD_STYLES)}
549
+ dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
550
+ />
551
+ ) : (
552
+ <div key={i} className="whitespace-pre-wrap text-sm leading-relaxed text-base-contrast">{m.content}</div>
553
+ ),
554
+ )}
555
+ {notice && (
556
+ <p className="text-xs text-base-contrast-light">
557
+ {notice}
558
+ {canRetry && !busy && (
559
+ <button type="button" onClick={retry} className="ml-2 cursor-pointer text-primary underline underline-offset-2">
560
+ Try again
561
+ </button>
562
+ )}
563
+ </p>
564
+ )}
565
+ </div>
566
+ {showHistory && persistent && (
567
+ <div className="absolute inset-0 z-20 overflow-y-auto bg-base p-2" role="region" aria-label="Saved conversations">
568
+ <p className="px-1 pb-2 text-xs font-medium text-base-contrast-light">
569
+ Chat history — pinned conversations are kept forever; others expire after 30 days.
570
+ </p>
571
+ {history.length === 0 ? (
572
+ <p className="px-1 text-xs text-base-contrast-light">No saved conversations yet.</p>
573
+ ) : (
574
+ <ul className="space-y-1">
575
+ {history.map((c) => (
576
+ <li key={c.id} className="flex items-center gap-1 rounded-inner border border-base-200 px-2 py-1.5 hover:bg-base-accent">
577
+ <button type="button" onClick={() => restoreConversation(c)} className="min-w-0 flex-1 cursor-pointer text-left">
578
+ <span className="block truncate text-sm text-base-contrast">{c.title}</span>
579
+ <span className="block text-[10px] text-base-contrast-light">
580
+ {c.pinned ? "Pinned · " : ""}
581
+ {new Date(c.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
582
+ </span>
583
+ </button>
584
+ <IconButton icon={c.pinned ? <PinOff size={14} /> : <Pin size={14} />}
585
+ label={c.pinned ? `Unpin "${c.title}"` : `Pin "${c.title}"`}
586
+ onClick={() => togglePinned(c)} className="rounded-inner hover:bg-base-200" />
587
+ <IconButton icon={<Trash2 size={14} />} label={`Delete "${c.title}"`}
588
+ onClick={() => deleteFromHistory(c)} className="rounded-inner hover:bg-base-200" />
589
+ </li>
590
+ ))}
591
+ </ul>
592
+ )}
593
+ </div>
594
+ )}
595
+ {jumpVisible && (
596
+ <button
597
+ type="button"
598
+ onClick={jumpToLatest}
599
+ className="absolute bottom-2 left-1/2 z-10 flex -translate-x-1/2 cursor-pointer items-center gap-1 rounded-full border border-base-200 bg-base px-3 py-1 text-xs text-base-contrast shadow-md hover:bg-base-accent"
600
+ >
601
+ <ArrowDown size={12} aria-hidden="true" /> Latest
602
+ </button>
603
+ )}
604
+ </div>
605
+ <p role="status" className="sr-only">{busy ? thinking : ""}</p>
606
+ <form
607
+ className={cn(
608
+ "flex shrink-0 items-end gap-2 border-t border-base-200 p-2",
609
+ mode === "sheet" && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
610
+ )}
611
+ onSubmit={(e) => { e.preventDefault(); void send(); }}
612
+ >
613
+ <textarea
614
+ ref={composerRef}
615
+ rows={1}
616
+ value={input}
617
+ onChange={(e) => setInput(e.target.value)}
618
+ onKeyDown={onComposerKeyDown}
619
+ placeholder={`Ask about ${siteName}…`}
620
+ maxLength={4000}
621
+ className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded-inner border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
622
+ />
623
+ {busy ? (
624
+ <button type="button" aria-label="Stop" onClick={stop}
625
+ className="flex cursor-pointer items-center rounded-inner bg-primary px-3 py-1.5 text-sm text-primary-contrast">
626
+ <Square size={12} fill="currentColor" aria-hidden="true" />
627
+ </button>
628
+ ) : (
629
+ <button type="submit" aria-label="Send"
630
+ className="rounded-inner bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
631
+ )}
632
+ </form>
633
+ {isEditor && <p className="shrink-0 border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
247
634
  </div>
248
- <form className="flex items-end gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
249
- <textarea
250
- ref={composerRef}
251
- rows={1}
252
- value={input}
253
- onChange={(e) => setInput(e.target.value)}
254
- onKeyDown={onComposerKeyDown}
255
- placeholder={`Ask about ${siteName}…`}
256
- maxLength={4000}
257
- className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded-inner border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
258
- />
259
- <button type="submit" aria-label="Send" disabled={busy}
260
- className="rounded-inner bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
261
- </form>
262
- {isEditor && <p className="border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
263
- </div>
635
+ </>
264
636
  );
265
637
  }