radiant-docs 0.1.28 → 0.1.31

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.
@@ -0,0 +1,1771 @@
1
+ import type { JSX } from "preact";
2
+ import { useEffect, useRef, useState } from "preact/hooks";
3
+ import { Icon } from "@iconify/react";
4
+ import Prism from "prismjs";
5
+ import "prismjs/components/prism-markup.js";
6
+ import "prismjs/components/prism-clike.js";
7
+ import "prismjs/components/prism-javascript.js";
8
+ import "prismjs/components/prism-typescript.js";
9
+ import "prismjs/components/prism-jsx.js";
10
+ import "prismjs/components/prism-tsx.js";
11
+ import "prismjs/components/prism-json.js";
12
+ import "prismjs/components/prism-markdown.js";
13
+ import "prismjs/components/prism-bash.js";
14
+ import "prismjs/components/prism-python.js";
15
+ import "prismjs/components/prism-yaml.js";
16
+ import "prismjs/components/prism-sql.js";
17
+ import "prismjs/components/prism-rust.js";
18
+ import "prismjs/components/prism-go.js";
19
+ import "prismjs/components/prism-java.js";
20
+ import "prismjs/components/prism-markup-templating.js";
21
+ import "prismjs/components/prism-php.js";
22
+ import "prismjs/components/prism-ruby.js";
23
+ import "prismjs/components/prism-css.js";
24
+ import "prismjs/components/prism-diff.js";
25
+ // import "prismjs/themes/prism.css";
26
+ import "prism-themes/themes/prism-one-light.css";
27
+ // import "@jongwooo/prism-theme-github/themes/prism-github-default-light.css";
28
+ import { unified } from "unified";
29
+ import remarkParse from "remark-parse";
30
+ import remarkGfm from "remark-gfm";
31
+ import remarkRehype from "remark-rehype";
32
+ import rehypeStringify from "rehype-stringify";
33
+ import { Drawer } from "vaul";
34
+ import rehypeExternalLinks from "../../lib/mdx/rehype-external-links";
35
+ import sparkleIcon from "../../assets/icons/sparkle.svg?url";
36
+
37
+ type AskAiWidgetProps = {
38
+ apiPath: string;
39
+ isChatAvailable: boolean;
40
+ unavailableMessage?: string;
41
+ devProxyToken?: string;
42
+ };
43
+
44
+ type ChatMessage = {
45
+ id: string;
46
+ role: "user" | "assistant";
47
+ content: string;
48
+ };
49
+
50
+ type AskAiStreamEvent = {
51
+ type?: string;
52
+ delta?: string;
53
+ };
54
+
55
+ type ResizeHandlePointerEvent = JSX.TargetedPointerEvent<HTMLDivElement>;
56
+ type DesktopWidgetWheelEvent = JSX.TargetedWheelEvent<HTMLDivElement>;
57
+ type ChatInputKeyEvent = JSX.TargetedKeyboardEvent<HTMLTextAreaElement>;
58
+
59
+ type DesktopSize = {
60
+ width: number;
61
+ height: number;
62
+ };
63
+
64
+ type InlineStyleSnapshot = {
65
+ transform: string;
66
+ transformOrigin: string;
67
+ transitionProperty: string;
68
+ transitionDuration: string;
69
+ transitionTimingFunction: string;
70
+ overflow: string;
71
+ };
72
+
73
+ type PersistedWidgetState = {
74
+ isOpen: boolean;
75
+ messages: ChatMessage[];
76
+ input: string;
77
+ desktopWidth: number;
78
+ desktopHeight: number;
79
+ threadScrollTop: number;
80
+ };
81
+
82
+ const STORAGE_KEY = "radiant-docs:ask-ai-widget:v1";
83
+ const DESKTOP_MIN_WIDTH_PX = 320;
84
+ const DESKTOP_MAX_WIDTH_PX = 768 + 12 + 12;
85
+ const DESKTOP_DEFAULT_WIDTH_PX = 384;
86
+ const DESKTOP_MIN_HEIGHT_PX = 320;
87
+ const DESKTOP_DEFAULT_HEIGHT_PX = 448;
88
+ const DESKTOP_MAX_HEIGHT_OFFSET_PX = 20 + 4 + 8;
89
+ const MOBILE_DRAWER_TRANSITION_MS = 500;
90
+ const VAUL_SCALE_WINDOW_TOP_OFFSET_PX = 26;
91
+ const VAUL_SCALE_TRANSLATE_TOP_OFFSET_PX = 14;
92
+ const VAUL_SCALE_TRANSITION_EASE = "cubic-bezier(0.32, 0.72, 0, 1)";
93
+ const MARKDOWN_HTML_CACHE_LIMIT = 300;
94
+ const markdownHtmlCache = new Map<string, string>();
95
+ const PRISM_LANGUAGE_ALIAS: Record<string, string> = {
96
+ js: "javascript",
97
+ ts: "typescript",
98
+ yml: "yaml",
99
+ sh: "bash",
100
+ shell: "bash",
101
+ shellscript: "bash",
102
+ md: "markdown",
103
+ mdx: "markdown",
104
+ };
105
+
106
+ const PRISM_LANGUAGE_LABEL: Record<string, string> = {
107
+ javascript: "JavaScript",
108
+ typescript: "TypeScript",
109
+ jsx: "JSX",
110
+ tsx: "TSX",
111
+ json: "JSON",
112
+ markdown: "Markdown",
113
+ bash: "Bash",
114
+ shell: "Shell",
115
+ python: "Python",
116
+ yaml: "YAML",
117
+ sql: "SQL",
118
+ rust: "Rust",
119
+ go: "Go",
120
+ java: "Java",
121
+ css: "CSS",
122
+ html: "HTML",
123
+ diff: "Diff",
124
+ mdx: "MDX",
125
+ };
126
+
127
+ function clamp(value: number, min: number, max: number) {
128
+ return Math.min(Math.max(value, min), max);
129
+ }
130
+
131
+ function getViewportHeightPx() {
132
+ if (typeof window === "undefined") {
133
+ return DESKTOP_DEFAULT_HEIGHT_PX + DESKTOP_MAX_HEIGHT_OFFSET_PX;
134
+ }
135
+ return window.visualViewport?.height ?? window.innerHeight;
136
+ }
137
+
138
+ function getDesktopMaxHeightPx() {
139
+ return Math.max(
140
+ DESKTOP_MIN_HEIGHT_PX,
141
+ getViewportHeightPx() - DESKTOP_MAX_HEIGHT_OFFSET_PX,
142
+ );
143
+ }
144
+
145
+ function getDefaultPersistedState(): PersistedWidgetState {
146
+ return {
147
+ isOpen: false,
148
+ messages: [],
149
+ input: "",
150
+ desktopWidth: DESKTOP_DEFAULT_WIDTH_PX,
151
+ desktopHeight: DESKTOP_DEFAULT_HEIGHT_PX,
152
+ threadScrollTop: 0,
153
+ };
154
+ }
155
+
156
+ function readPersistedState(): PersistedWidgetState {
157
+ if (typeof window === "undefined") {
158
+ return getDefaultPersistedState();
159
+ }
160
+
161
+ try {
162
+ const raw = window.localStorage.getItem(STORAGE_KEY);
163
+ if (!raw) {
164
+ return getDefaultPersistedState();
165
+ }
166
+
167
+ const parsed = JSON.parse(raw) as Partial<PersistedWidgetState>;
168
+ const parsedMessages = Array.isArray(parsed.messages)
169
+ ? parsed.messages
170
+ .filter((message) => {
171
+ if (!message || typeof message !== "object") {
172
+ return false;
173
+ }
174
+ const candidate = message as Partial<ChatMessage>;
175
+ return (
176
+ (candidate.role === "user" || candidate.role === "assistant") &&
177
+ typeof candidate.id === "string" &&
178
+ typeof candidate.content === "string"
179
+ );
180
+ })
181
+ .map((message) => {
182
+ const candidate = message as ChatMessage;
183
+ return {
184
+ id: candidate.id,
185
+ role: candidate.role,
186
+ content: candidate.content,
187
+ };
188
+ })
189
+ : [];
190
+
191
+ return {
192
+ isOpen: parsed.isOpen === true,
193
+ messages: parsedMessages,
194
+ input: typeof parsed.input === "string" ? parsed.input : "",
195
+ desktopWidth:
196
+ typeof parsed.desktopWidth === "number" &&
197
+ Number.isFinite(parsed.desktopWidth)
198
+ ? parsed.desktopWidth
199
+ : DESKTOP_DEFAULT_WIDTH_PX,
200
+ desktopHeight:
201
+ typeof parsed.desktopHeight === "number" &&
202
+ Number.isFinite(parsed.desktopHeight)
203
+ ? parsed.desktopHeight
204
+ : DESKTOP_DEFAULT_HEIGHT_PX,
205
+ threadScrollTop:
206
+ typeof parsed.threadScrollTop === "number" &&
207
+ Number.isFinite(parsed.threadScrollTop) &&
208
+ parsed.threadScrollTop >= 0
209
+ ? parsed.threadScrollTop
210
+ : 0,
211
+ };
212
+ } catch {
213
+ return getDefaultPersistedState();
214
+ }
215
+ }
216
+
217
+ function escapeHtml(value: string): string {
218
+ return value
219
+ .replaceAll("&", "&amp;")
220
+ .replaceAll("<", "&lt;")
221
+ .replaceAll(">", "&gt;")
222
+ .replaceAll('"', "&quot;")
223
+ .replaceAll("'", "&#39;");
224
+ }
225
+
226
+ function resolvePrismLanguage(rawLanguage: string): string {
227
+ const normalized = rawLanguage.trim().toLowerCase();
228
+ return PRISM_LANGUAGE_ALIAS[normalized] ?? normalized;
229
+ }
230
+
231
+ function resolvePrismLanguageLabel(language: string): string {
232
+ const normalized = language.trim().toLowerCase();
233
+ if (!normalized) {
234
+ return "";
235
+ }
236
+
237
+ return (
238
+ PRISM_LANGUAGE_LABEL[normalized] ??
239
+ normalized
240
+ .replace(/[-_]+/g, " ")
241
+ .replace(/\b\w/g, (char) => char.toUpperCase())
242
+ );
243
+ }
244
+
245
+ function resolvePrismGrammar(language: string) {
246
+ return (
247
+ Prism.languages[language] ??
248
+ Prism.languages[PRISM_LANGUAGE_ALIAS[language] ?? ""]
249
+ );
250
+ }
251
+
252
+ function ensureCodeBlockCopyButton(preElement: HTMLPreElement): void {
253
+ const existingButton = Array.from(preElement.children).find(
254
+ (child) =>
255
+ child instanceof HTMLButtonElement &&
256
+ child.classList.contains("ask-ai-copy-code-button"),
257
+ );
258
+
259
+ if (existingButton instanceof HTMLButtonElement) {
260
+ return;
261
+ }
262
+
263
+ const copyButton = document.createElement("button");
264
+ copyButton.type = "button";
265
+ copyButton.className = "ask-ai-copy-code-button";
266
+ copyButton.setAttribute("data-copy-code", "true");
267
+ copyButton.setAttribute("aria-label", "Copy code to clipboard");
268
+ copyButton.setAttribute("title", "Copy code");
269
+
270
+ const copyIcon = document.createElement("span");
271
+ copyIcon.className = "ask-ai-copy-icon";
272
+ copyIcon.setAttribute("aria-hidden", "true");
273
+
274
+ const checkIcon = document.createElement("span");
275
+ checkIcon.className = "ask-ai-copy-check-icon";
276
+ checkIcon.setAttribute("aria-hidden", "true");
277
+
278
+ copyButton.append(copyIcon, checkIcon);
279
+ preElement.append(copyButton);
280
+ }
281
+
282
+ async function copyToClipboard(text: string): Promise<boolean> {
283
+ if (
284
+ typeof navigator !== "undefined" &&
285
+ navigator.clipboard &&
286
+ typeof navigator.clipboard.writeText === "function"
287
+ ) {
288
+ try {
289
+ await navigator.clipboard.writeText(text);
290
+ return true;
291
+ } catch {
292
+ // Fall through to legacy copy fallback.
293
+ }
294
+ }
295
+
296
+ if (typeof document === "undefined") {
297
+ return false;
298
+ }
299
+
300
+ try {
301
+ const textarea = document.createElement("textarea");
302
+ textarea.value = text;
303
+ textarea.setAttribute("readonly", "true");
304
+ textarea.style.position = "fixed";
305
+ textarea.style.top = "-9999px";
306
+ textarea.style.opacity = "0";
307
+ document.body.append(textarea);
308
+ textarea.select();
309
+ const didCopy = document.execCommand("copy");
310
+ textarea.remove();
311
+ return didCopy;
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ function unwrapEscapedFenceCodeBlock(rawCode: string): {
318
+ language: string;
319
+ code: string;
320
+ } | null {
321
+ const normalizedCode = rawCode.replaceAll("\r\n", "\n").trim();
322
+ if (!normalizedCode) {
323
+ return null;
324
+ }
325
+
326
+ const lines = normalizedCode.split("\n");
327
+ if (lines.length < 2) {
328
+ return null;
329
+ }
330
+
331
+ const openingLine = lines[0]?.trim() ?? "";
332
+ const openingMatch = openingLine.match(/^(`{3,})([^\s`]*)?(?:\s+[\s\S]*)?$/);
333
+ if (!openingMatch) {
334
+ return null;
335
+ }
336
+
337
+ const fenceToken = openingMatch[1];
338
+ const closingLine = lines.at(-1)?.trim() ?? "";
339
+ if (closingLine !== fenceToken) {
340
+ return null;
341
+ }
342
+
343
+ const languageToken = (openingMatch[2] ?? "").trim();
344
+ if (!languageToken) {
345
+ return null;
346
+ }
347
+
348
+ return {
349
+ language: resolvePrismLanguage(languageToken),
350
+ code: lines.slice(1, -1).join("\n"),
351
+ };
352
+ }
353
+
354
+ function highlightCodeBlocksInHtml(html: string): string {
355
+ if (typeof document === "undefined" || !html.trim()) {
356
+ return html;
357
+ }
358
+
359
+ try {
360
+ const container = document.createElement("div");
361
+ container.innerHTML = html;
362
+
363
+ const codeNodes = container.querySelectorAll("pre > code");
364
+ codeNodes.forEach((codeNode) => {
365
+ const getLanguageClassName = (element: Element | null) =>
366
+ Array.from(element?.classList ?? []).find(
367
+ (className) =>
368
+ className.startsWith("language-") || className.startsWith("lang-"),
369
+ );
370
+
371
+ const setLanguageClass = (language: string) => {
372
+ Array.from(codeNode.classList).forEach((className) => {
373
+ if (
374
+ className.startsWith("language-") ||
375
+ className.startsWith("lang-")
376
+ ) {
377
+ codeNode.classList.remove(className);
378
+ }
379
+ });
380
+ codeNode.classList.add(`language-${language}`);
381
+
382
+ const preParent = codeNode.parentElement;
383
+ if (preParent instanceof HTMLPreElement) {
384
+ Array.from(preParent.classList).forEach((className) => {
385
+ if (
386
+ className.startsWith("language-") ||
387
+ className.startsWith("lang-")
388
+ ) {
389
+ preParent.classList.remove(className);
390
+ }
391
+ });
392
+ preParent.classList.add(`language-${language}`);
393
+ const languageLabel = resolvePrismLanguageLabel(language);
394
+ if (languageLabel) {
395
+ preParent.setAttribute("data-language", languageLabel);
396
+ } else {
397
+ preParent.removeAttribute("data-language");
398
+ }
399
+
400
+ ensureCodeBlockCopyButton(preParent);
401
+ }
402
+ };
403
+
404
+ const codeLanguageClassName = getLanguageClassName(codeNode);
405
+ const preLanguageClassName = getLanguageClassName(codeNode.parentElement);
406
+ let rawLanguage =
407
+ codeLanguageClassName?.replace(/^(language-|lang-)/, "") ??
408
+ preLanguageClassName?.replace(/^(language-|lang-)/, "") ??
409
+ "";
410
+
411
+ let rawCode = codeNode.textContent ?? "";
412
+ if (!rawLanguage) {
413
+ const unwrappedFence = unwrapEscapedFenceCodeBlock(rawCode);
414
+ if (unwrappedFence) {
415
+ rawLanguage = unwrappedFence.language || rawLanguage;
416
+ rawCode = unwrappedFence.code;
417
+ }
418
+ }
419
+
420
+ if (!rawLanguage) {
421
+ return;
422
+ }
423
+
424
+ const resolvedLanguage = resolvePrismLanguage(rawLanguage);
425
+
426
+ const grammar =
427
+ resolvePrismGrammar(resolvedLanguage) ??
428
+ resolvePrismGrammar(rawLanguage);
429
+ if (!grammar) {
430
+ return;
431
+ }
432
+
433
+ setLanguageClass(resolvedLanguage);
434
+ try {
435
+ const highlighted = Prism.highlight(rawCode, grammar, resolvedLanguage);
436
+ codeNode.innerHTML = highlighted;
437
+ } catch (error) {
438
+ console.error("Ask AI code highlighting failed", {
439
+ language: resolvedLanguage,
440
+ error,
441
+ });
442
+ }
443
+ });
444
+
445
+ return container.innerHTML;
446
+ } catch (error) {
447
+ console.error("Ask AI markdown highlighting pipeline failed", error);
448
+ return html;
449
+ }
450
+ }
451
+
452
+ function renderMarkdownToHtml(markdown: string): string {
453
+ const normalizedMarkdown = markdown.replaceAll("\r\n", "\n");
454
+ const cached = markdownHtmlCache.get(normalizedMarkdown);
455
+ if (cached !== undefined) {
456
+ return cached;
457
+ }
458
+
459
+ let html = "";
460
+
461
+ try {
462
+ html = String(
463
+ unified()
464
+ .use(remarkParse)
465
+ .use(remarkGfm)
466
+ .use(remarkRehype, { allowDangerousHtml: false })
467
+ .use(rehypeExternalLinks)
468
+ .use(rehypeStringify)
469
+ .processSync(normalizedMarkdown),
470
+ );
471
+ } catch {
472
+ html = escapeHtml(normalizedMarkdown).replaceAll("\n", "<br/>");
473
+ }
474
+
475
+ html = highlightCodeBlocksInHtml(html);
476
+
477
+ markdownHtmlCache.set(normalizedMarkdown, html);
478
+ if (markdownHtmlCache.size > MARKDOWN_HTML_CACHE_LIMIT) {
479
+ const oldestKey = markdownHtmlCache.keys().next().value;
480
+ if (oldestKey) {
481
+ markdownHtmlCache.delete(oldestKey);
482
+ }
483
+ }
484
+
485
+ return html;
486
+ }
487
+
488
+ function createMessageId() {
489
+ if (
490
+ typeof crypto !== "undefined" &&
491
+ typeof crypto.randomUUID === "function"
492
+ ) {
493
+ return crypto.randomUUID();
494
+ }
495
+ return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
496
+ }
497
+
498
+ function extractErrorMessage(rawBody: string) {
499
+ if (!rawBody.trim()) {
500
+ return "";
501
+ }
502
+
503
+ try {
504
+ const parsed = JSON.parse(rawBody) as { error?: unknown; detail?: unknown };
505
+ if (typeof parsed.error === "string" && parsed.error.trim()) {
506
+ if (typeof parsed.detail === "string" && parsed.detail.trim()) {
507
+ return `${parsed.error}: ${parsed.detail}`;
508
+ }
509
+ return parsed.error;
510
+ }
511
+ } catch {
512
+ // Ignore JSON parse errors and fall back to raw text.
513
+ }
514
+
515
+ return rawBody.trim();
516
+ }
517
+
518
+ export default function AskAiWidget({
519
+ apiPath,
520
+ isChatAvailable,
521
+ unavailableMessage = "This feature is available on production sites for Pro tier organizations.",
522
+ devProxyToken,
523
+ }: AskAiWidgetProps) {
524
+ const [initialPersistedState] = useState(readPersistedState);
525
+ const [isOpen, setIsOpen] = useState(initialPersistedState.isOpen);
526
+ const [isMobile, setIsMobile] = useState(false);
527
+ const [desktopSize, setDesktopSize] = useState<DesktopSize>({
528
+ width: clamp(
529
+ initialPersistedState.desktopWidth,
530
+ DESKTOP_MIN_WIDTH_PX,
531
+ DESKTOP_MAX_WIDTH_PX,
532
+ ),
533
+ height: initialPersistedState.desktopHeight,
534
+ });
535
+ const [messages, setMessages] = useState<ChatMessage[]>(
536
+ initialPersistedState.messages,
537
+ );
538
+ const [input, setInput] = useState(initialPersistedState.input);
539
+ const [threadScrollTop, setThreadScrollTop] = useState(
540
+ initialPersistedState.threadScrollTop,
541
+ );
542
+ const [isBusy, setIsBusy] = useState(false);
543
+ const [isAwaitingFirstToken, setIsAwaitingFirstToken] = useState(false);
544
+ const [errorMessage, setErrorMessage] = useState("");
545
+ const activeRequestAbortRef = useRef<AbortController | null>(null);
546
+ const resizeModeRef = useRef<"left" | "top" | "corner" | null>(null);
547
+ const resizeStartPointerRef = useRef<{ x: number; y: number } | null>(null);
548
+ const resizeStartSizeRef = useRef<DesktopSize | null>(null);
549
+ const desktopScrollViewportRef = useRef<HTMLDivElement | null>(null);
550
+ const chatInputTextareaRef = useRef<HTMLTextAreaElement | null>(null);
551
+ const bodyBackgroundBeforeMobileDrawerRef = useRef<string | null>(null);
552
+ const bodyBackgroundResetTimeoutRef = useRef<number | null>(null);
553
+ const chromeScaleOriginalStylesRef = useRef<
554
+ Map<HTMLElement, InlineStyleSnapshot>
555
+ >(new Map());
556
+ const chromeScaleResetTimeoutRef = useRef<number | null>(null);
557
+ const threadScrollTopRef = useRef(initialPersistedState.threadScrollTop);
558
+ const hasRestoredThreadScrollRef = useRef(false);
559
+
560
+ const readCurrentThreadScrollTop = () => {
561
+ const viewport = desktopScrollViewportRef.current;
562
+ if (!viewport) {
563
+ return threadScrollTopRef.current;
564
+ }
565
+
566
+ const maxScrollTop = Math.max(
567
+ 0,
568
+ viewport.scrollHeight - viewport.clientHeight,
569
+ );
570
+ return clamp(viewport.scrollTop, 0, maxScrollTop);
571
+ };
572
+
573
+ const captureThreadScrollTop = () => {
574
+ const nextThreadScrollTop = readCurrentThreadScrollTop();
575
+ threadScrollTopRef.current = nextThreadScrollTop;
576
+ setThreadScrollTop((previous) =>
577
+ Math.abs(previous - nextThreadScrollTop) < 0.5
578
+ ? previous
579
+ : nextThreadScrollTop,
580
+ );
581
+ };
582
+
583
+ const handleOpenChange = (nextOpen: boolean) => {
584
+ if (!nextOpen) {
585
+ captureThreadScrollTop();
586
+ hasRestoredThreadScrollRef.current = false;
587
+ } else {
588
+ hasRestoredThreadScrollRef.current = false;
589
+ }
590
+ setIsOpen(nextOpen);
591
+ };
592
+
593
+ const handleThreadViewportScroll = () => {
594
+ threadScrollTopRef.current = readCurrentThreadScrollTop();
595
+ };
596
+
597
+ useEffect(() => {
598
+ return () => {
599
+ activeRequestAbortRef.current?.abort();
600
+ activeRequestAbortRef.current = null;
601
+ };
602
+ }, []);
603
+
604
+ useEffect(() => {
605
+ const mediaQuery = window.matchMedia("(max-width: 1023px)");
606
+ const update = () => {
607
+ setIsMobile(mediaQuery.matches);
608
+ };
609
+
610
+ update();
611
+
612
+ if (typeof mediaQuery.addEventListener === "function") {
613
+ mediaQuery.addEventListener("change", update);
614
+ return () => mediaQuery.removeEventListener("change", update);
615
+ }
616
+
617
+ mediaQuery.addListener(update);
618
+ return () => mediaQuery.removeListener(update);
619
+ }, []);
620
+
621
+ useEffect(() => {
622
+ const clampDesktopSizeToViewport = () => {
623
+ setDesktopSize((previous) => {
624
+ const maxHeight = getDesktopMaxHeightPx();
625
+ const nextWidth = clamp(
626
+ previous.width,
627
+ DESKTOP_MIN_WIDTH_PX,
628
+ DESKTOP_MAX_WIDTH_PX,
629
+ );
630
+ const nextHeight = clamp(
631
+ previous.height,
632
+ DESKTOP_MIN_HEIGHT_PX,
633
+ maxHeight,
634
+ );
635
+
636
+ if (nextWidth === previous.width && nextHeight === previous.height) {
637
+ return previous;
638
+ }
639
+
640
+ return {
641
+ width: nextWidth,
642
+ height: nextHeight,
643
+ };
644
+ });
645
+ };
646
+
647
+ clampDesktopSizeToViewport();
648
+ window.addEventListener("resize", clampDesktopSizeToViewport);
649
+ window.visualViewport?.addEventListener(
650
+ "resize",
651
+ clampDesktopSizeToViewport,
652
+ );
653
+ return () => {
654
+ window.removeEventListener("resize", clampDesktopSizeToViewport);
655
+ window.visualViewport?.removeEventListener(
656
+ "resize",
657
+ clampDesktopSizeToViewport,
658
+ );
659
+ };
660
+ }, []);
661
+
662
+ useEffect(() => {
663
+ const openFromHeader = () => {
664
+ handleOpenChange(true);
665
+ };
666
+
667
+ window.addEventListener("ask-ai:open", openFromHeader);
668
+ return () => {
669
+ window.removeEventListener("ask-ai:open", openFromHeader);
670
+ };
671
+ }, []);
672
+
673
+ useEffect(() => {
674
+ if (typeof window === "undefined" || !isOpen) {
675
+ return;
676
+ }
677
+
678
+ if (hasRestoredThreadScrollRef.current) {
679
+ return;
680
+ }
681
+
682
+ const viewport = desktopScrollViewportRef.current;
683
+ if (!viewport) {
684
+ return;
685
+ }
686
+
687
+ const restoreFrame = window.requestAnimationFrame(() => {
688
+ const maxScrollTop = Math.max(
689
+ 0,
690
+ viewport.scrollHeight - viewport.clientHeight,
691
+ );
692
+ const nextScrollTop = clamp(threadScrollTopRef.current, 0, maxScrollTop);
693
+ viewport.scrollTop = nextScrollTop;
694
+ threadScrollTopRef.current = nextScrollTop;
695
+ hasRestoredThreadScrollRef.current = true;
696
+ });
697
+
698
+ return () => {
699
+ window.cancelAnimationFrame(restoreFrame);
700
+ };
701
+ }, [isOpen, isMobile, messages.length]);
702
+
703
+ useEffect(() => {
704
+ if (typeof document === "undefined" || typeof window === "undefined") {
705
+ return;
706
+ }
707
+
708
+ if (chromeScaleResetTimeoutRef.current !== null) {
709
+ window.clearTimeout(chromeScaleResetTimeoutRef.current);
710
+ chromeScaleResetTimeoutRef.current = null;
711
+ }
712
+
713
+ const chromeElements = Array.from(
714
+ document.querySelectorAll<HTMLElement>("[data-vaul-scale-chrome]"),
715
+ );
716
+ const sharedChromePivotX = window.innerWidth / 2;
717
+ const sharedChromePivotY = 0;
718
+ const resolveSharedChromeTransformOrigin = (element: HTMLElement) => {
719
+ const rect = element.getBoundingClientRect();
720
+ return `${sharedChromePivotX - rect.left}px ${sharedChromePivotY - rect.top}px`;
721
+ };
722
+
723
+ const hasConnectedSnapshotElement = Array.from(
724
+ chromeScaleOriginalStylesRef.current.keys(),
725
+ ).some((element) => element.isConnected);
726
+
727
+ if (isMobile && isOpen) {
728
+ if (
729
+ chromeScaleOriginalStylesRef.current.size === 0 ||
730
+ !hasConnectedSnapshotElement
731
+ ) {
732
+ chromeScaleOriginalStylesRef.current = new Map(
733
+ chromeElements.map((element) => [
734
+ element,
735
+ {
736
+ transform: element.style.transform,
737
+ transformOrigin: element.style.transformOrigin,
738
+ transitionProperty: element.style.transitionProperty,
739
+ transitionDuration: element.style.transitionDuration,
740
+ transitionTimingFunction: element.style.transitionTimingFunction,
741
+ overflow: element.style.overflow,
742
+ },
743
+ ]),
744
+ );
745
+ }
746
+
747
+ const wrapperScale =
748
+ (window.innerWidth - VAUL_SCALE_WINDOW_TOP_OFFSET_PX) /
749
+ window.innerWidth;
750
+ const nextTransform = `scale(${wrapperScale}) translate3d(0, calc(env(safe-area-inset-top) + ${VAUL_SCALE_TRANSLATE_TOP_OFFSET_PX}px), 0)`;
751
+
752
+ for (const element of chromeElements) {
753
+ element.style.transformOrigin =
754
+ resolveSharedChromeTransformOrigin(element);
755
+ element.style.transitionProperty = "transform";
756
+ element.style.transitionDuration = `${MOBILE_DRAWER_TRANSITION_MS}ms`;
757
+ element.style.transitionTimingFunction = VAUL_SCALE_TRANSITION_EASE;
758
+ element.style.transform = nextTransform;
759
+ element.style.overflow = "hidden";
760
+ }
761
+ return;
762
+ }
763
+
764
+ if (chromeScaleOriginalStylesRef.current.size === 0) {
765
+ return;
766
+ }
767
+
768
+ const snapshots = chromeScaleOriginalStylesRef.current;
769
+ for (const [element, snapshot] of snapshots) {
770
+ if (!element.isConnected) {
771
+ continue;
772
+ }
773
+
774
+ element.style.transitionProperty = "transform";
775
+ element.style.transitionDuration = `${MOBILE_DRAWER_TRANSITION_MS}ms`;
776
+ element.style.transitionTimingFunction = VAUL_SCALE_TRANSITION_EASE;
777
+ element.style.transform = snapshot.transform;
778
+ element.style.overflow = snapshot.overflow;
779
+ }
780
+
781
+ chromeScaleResetTimeoutRef.current = window.setTimeout(() => {
782
+ for (const [element, snapshot] of snapshots) {
783
+ if (!element.isConnected) {
784
+ continue;
785
+ }
786
+ element.style.transform = snapshot.transform;
787
+ element.style.transformOrigin = snapshot.transformOrigin;
788
+ element.style.transitionProperty = snapshot.transitionProperty;
789
+ element.style.transitionDuration = snapshot.transitionDuration;
790
+ element.style.transitionTimingFunction =
791
+ snapshot.transitionTimingFunction;
792
+ element.style.overflow = snapshot.overflow;
793
+ }
794
+ chromeScaleOriginalStylesRef.current = new Map();
795
+ chromeScaleResetTimeoutRef.current = null;
796
+ }, MOBILE_DRAWER_TRANSITION_MS);
797
+ }, [isMobile, isOpen]);
798
+
799
+ useEffect(() => {
800
+ if (typeof document === "undefined") {
801
+ return;
802
+ }
803
+
804
+ if (typeof window === "undefined") {
805
+ return;
806
+ }
807
+
808
+ if (bodyBackgroundResetTimeoutRef.current !== null) {
809
+ window.clearTimeout(bodyBackgroundResetTimeoutRef.current);
810
+ bodyBackgroundResetTimeoutRef.current = null;
811
+ }
812
+
813
+ if (isMobile && isOpen) {
814
+ if (bodyBackgroundBeforeMobileDrawerRef.current === null) {
815
+ bodyBackgroundBeforeMobileDrawerRef.current =
816
+ document.body.style.backgroundColor;
817
+ }
818
+ document.body.style.backgroundColor = "var(--color-neutral-100)";
819
+ return;
820
+ }
821
+
822
+ if (bodyBackgroundBeforeMobileDrawerRef.current === null) {
823
+ return;
824
+ }
825
+
826
+ const previousBodyBackgroundColor =
827
+ bodyBackgroundBeforeMobileDrawerRef.current;
828
+ bodyBackgroundResetTimeoutRef.current = window.setTimeout(() => {
829
+ document.body.style.backgroundColor = previousBodyBackgroundColor;
830
+ bodyBackgroundBeforeMobileDrawerRef.current = null;
831
+ bodyBackgroundResetTimeoutRef.current = null;
832
+ }, MOBILE_DRAWER_TRANSITION_MS);
833
+ }, [isMobile, isOpen]);
834
+
835
+ useEffect(() => {
836
+ return () => {
837
+ if (
838
+ typeof window !== "undefined" &&
839
+ bodyBackgroundResetTimeoutRef.current !== null
840
+ ) {
841
+ window.clearTimeout(bodyBackgroundResetTimeoutRef.current);
842
+ bodyBackgroundResetTimeoutRef.current = null;
843
+ }
844
+
845
+ if (
846
+ typeof window !== "undefined" &&
847
+ chromeScaleResetTimeoutRef.current !== null
848
+ ) {
849
+ window.clearTimeout(chromeScaleResetTimeoutRef.current);
850
+ chromeScaleResetTimeoutRef.current = null;
851
+ }
852
+
853
+ if (
854
+ typeof document !== "undefined" &&
855
+ bodyBackgroundBeforeMobileDrawerRef.current !== null
856
+ ) {
857
+ document.body.style.backgroundColor =
858
+ bodyBackgroundBeforeMobileDrawerRef.current;
859
+ bodyBackgroundBeforeMobileDrawerRef.current = null;
860
+ }
861
+
862
+ for (const [element, snapshot] of chromeScaleOriginalStylesRef.current) {
863
+ if (!element.isConnected) {
864
+ continue;
865
+ }
866
+ element.style.transform = snapshot.transform;
867
+ element.style.transformOrigin = snapshot.transformOrigin;
868
+ element.style.transitionProperty = snapshot.transitionProperty;
869
+ element.style.transitionDuration = snapshot.transitionDuration;
870
+ element.style.transitionTimingFunction =
871
+ snapshot.transitionTimingFunction;
872
+ element.style.overflow = snapshot.overflow;
873
+ }
874
+ chromeScaleOriginalStylesRef.current = new Map();
875
+ };
876
+ }, []);
877
+
878
+ useEffect(() => {
879
+ if (typeof window === "undefined") {
880
+ return;
881
+ }
882
+
883
+ const timeoutId = window.setTimeout(() => {
884
+ try {
885
+ const persistedState: PersistedWidgetState = {
886
+ isOpen,
887
+ messages,
888
+ input,
889
+ desktopWidth: desktopSize.width,
890
+ desktopHeight: desktopSize.height,
891
+ threadScrollTop,
892
+ };
893
+ window.localStorage.setItem(
894
+ STORAGE_KEY,
895
+ JSON.stringify(persistedState),
896
+ );
897
+ } catch {
898
+ // Ignore storage write errors (quota/private mode).
899
+ }
900
+ }, 120);
901
+
902
+ return () => {
903
+ window.clearTimeout(timeoutId);
904
+ };
905
+ }, [
906
+ isOpen,
907
+ messages,
908
+ input,
909
+ desktopSize.width,
910
+ desktopSize.height,
911
+ threadScrollTop,
912
+ ]);
913
+
914
+ useEffect(() => {
915
+ const finishResize = () => {
916
+ if (resizeModeRef.current) {
917
+ resizeModeRef.current = null;
918
+ resizeStartPointerRef.current = null;
919
+ resizeStartSizeRef.current = null;
920
+ document.body.style.userSelect = "";
921
+ document.body.style.cursor = "";
922
+ }
923
+ };
924
+
925
+ const handlePointerMove = (event: PointerEvent) => {
926
+ if (
927
+ !resizeModeRef.current ||
928
+ !resizeStartPointerRef.current ||
929
+ !resizeStartSizeRef.current
930
+ ) {
931
+ return;
932
+ }
933
+
934
+ const deltaX = event.clientX - resizeStartPointerRef.current.x;
935
+ const deltaY = event.clientY - resizeStartPointerRef.current.y;
936
+ const mode = resizeModeRef.current;
937
+ const maxHeight = getDesktopMaxHeightPx();
938
+
939
+ let nextWidth = resizeStartSizeRef.current.width;
940
+ let nextHeight = resizeStartSizeRef.current.height;
941
+
942
+ if (mode === "left" || mode === "corner") {
943
+ nextWidth = clamp(
944
+ resizeStartSizeRef.current.width - deltaX,
945
+ DESKTOP_MIN_WIDTH_PX,
946
+ DESKTOP_MAX_WIDTH_PX,
947
+ );
948
+ }
949
+
950
+ if (mode === "top" || mode === "corner") {
951
+ nextHeight = clamp(
952
+ resizeStartSizeRef.current.height - deltaY,
953
+ DESKTOP_MIN_HEIGHT_PX,
954
+ maxHeight,
955
+ );
956
+ }
957
+
958
+ setDesktopSize((previous) => {
959
+ if (previous.width === nextWidth && previous.height === nextHeight) {
960
+ return previous;
961
+ }
962
+
963
+ return {
964
+ width: nextWidth,
965
+ height: nextHeight,
966
+ };
967
+ });
968
+ };
969
+
970
+ window.addEventListener("pointermove", handlePointerMove);
971
+ window.addEventListener("pointerup", finishResize);
972
+ window.addEventListener("pointercancel", finishResize);
973
+ return () => {
974
+ window.removeEventListener("pointermove", handlePointerMove);
975
+ window.removeEventListener("pointerup", finishResize);
976
+ window.removeEventListener("pointercancel", finishResize);
977
+ };
978
+ }, []);
979
+
980
+ const beginDesktopResize =
981
+ (mode: "left" | "top" | "corner") => (event: ResizeHandlePointerEvent) => {
982
+ if (isMobile) {
983
+ return;
984
+ }
985
+
986
+ event.preventDefault();
987
+ event.stopPropagation();
988
+
989
+ resizeModeRef.current = mode;
990
+ resizeStartPointerRef.current = {
991
+ x: event.clientX,
992
+ y: event.clientY,
993
+ };
994
+ resizeStartSizeRef.current = {
995
+ width: desktopSize.width,
996
+ height: desktopSize.height,
997
+ };
998
+
999
+ document.body.style.userSelect = "none";
1000
+ document.body.style.cursor =
1001
+ mode === "left"
1002
+ ? "ew-resize"
1003
+ : mode === "top"
1004
+ ? "ns-resize"
1005
+ : "nwse-resize";
1006
+ };
1007
+
1008
+ const handleDesktopWheelCapture = (event: DesktopWidgetWheelEvent) => {
1009
+ if (isMobile || !isOpen) {
1010
+ return;
1011
+ }
1012
+
1013
+ const inputTextarea = chatInputTextareaRef.current;
1014
+ let remainingDeltaY = event.deltaY;
1015
+ const eventTargetElement =
1016
+ event.target instanceof Element ? event.target : null;
1017
+
1018
+ if (
1019
+ inputTextarea &&
1020
+ event.target instanceof Node &&
1021
+ inputTextarea.contains(event.target)
1022
+ ) {
1023
+ const inputMaxScrollTop =
1024
+ inputTextarea.scrollHeight - inputTextarea.clientHeight;
1025
+
1026
+ event.preventDefault();
1027
+
1028
+ if (inputMaxScrollTop > 0) {
1029
+ const previousInputScrollTop = inputTextarea.scrollTop;
1030
+ const nextInputScrollTop = clamp(
1031
+ previousInputScrollTop + remainingDeltaY,
1032
+ 0,
1033
+ inputMaxScrollTop,
1034
+ );
1035
+
1036
+ inputTextarea.scrollTop = nextInputScrollTop;
1037
+ remainingDeltaY -= nextInputScrollTop - previousInputScrollTop;
1038
+
1039
+ if (Math.abs(remainingDeltaY) < 0.5) {
1040
+ return;
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ const codeBlockPre = eventTargetElement?.closest(
1046
+ ".ask-ai-markdown pre",
1047
+ ) as HTMLPreElement | null;
1048
+
1049
+ if (codeBlockPre) {
1050
+ const maxScrollLeft = Math.max(
1051
+ 0,
1052
+ codeBlockPre.scrollWidth - codeBlockPre.clientWidth,
1053
+ );
1054
+ const horizontalIntentDelta =
1055
+ Math.abs(event.deltaX) > 0.01
1056
+ ? event.deltaX
1057
+ : event.shiftKey
1058
+ ? event.deltaY
1059
+ : 0;
1060
+
1061
+ if (Math.abs(horizontalIntentDelta) > 0.01) {
1062
+ event.preventDefault();
1063
+
1064
+ if (maxScrollLeft > 0) {
1065
+ codeBlockPre.scrollLeft = clamp(
1066
+ codeBlockPre.scrollLeft + horizontalIntentDelta,
1067
+ 0,
1068
+ maxScrollLeft,
1069
+ );
1070
+ }
1071
+
1072
+ // Shift+wheel maps to horizontal intent; don't also move chat vertically.
1073
+ if (event.shiftKey && Math.abs(event.deltaX) <= 0.01) {
1074
+ return;
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ const scrollViewport = desktopScrollViewportRef.current;
1080
+ if (!scrollViewport) {
1081
+ event.preventDefault();
1082
+ return;
1083
+ }
1084
+
1085
+ event.preventDefault();
1086
+
1087
+ const maxScrollTop =
1088
+ scrollViewport.scrollHeight - scrollViewport.clientHeight;
1089
+ if (maxScrollTop <= 0) {
1090
+ return;
1091
+ }
1092
+
1093
+ const nextDeltaY =
1094
+ Math.abs(remainingDeltaY) < 0.5 ? event.deltaY : remainingDeltaY;
1095
+
1096
+ scrollViewport.scrollTop = clamp(
1097
+ scrollViewport.scrollTop + nextDeltaY,
1098
+ 0,
1099
+ maxScrollTop,
1100
+ );
1101
+ };
1102
+
1103
+ const resizeChatInputTextarea = () => {
1104
+ const textarea = chatInputTextareaRef.current;
1105
+ if (!textarea || typeof window === "undefined") {
1106
+ return;
1107
+ }
1108
+
1109
+ textarea.style.height = "auto";
1110
+
1111
+ const computedStyle = window.getComputedStyle(textarea);
1112
+ const lineHeightRaw = Number.parseFloat(computedStyle.lineHeight);
1113
+ const paddingTopRaw = Number.parseFloat(computedStyle.paddingTop);
1114
+ const paddingBottomRaw = Number.parseFloat(computedStyle.paddingBottom);
1115
+
1116
+ const lineHeight = Number.isFinite(lineHeightRaw) ? lineHeightRaw : 20;
1117
+ const paddingTop = Number.isFinite(paddingTopRaw) ? paddingTopRaw : 0;
1118
+ const paddingBottom = Number.isFinite(paddingBottomRaw)
1119
+ ? paddingBottomRaw
1120
+ : 0;
1121
+
1122
+ const maxHeight = lineHeight * 11.2 + paddingTop + paddingBottom;
1123
+ const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
1124
+
1125
+ textarea.style.height = `${nextHeight}px`;
1126
+ textarea.style.overflowY =
1127
+ textarea.scrollHeight > maxHeight ? "auto" : "hidden";
1128
+ };
1129
+
1130
+ useEffect(() => {
1131
+ resizeChatInputTextarea();
1132
+ }, [input, isOpen, isMobile, desktopSize.width]);
1133
+
1134
+ const handleSubmit = async (event: Event) => {
1135
+ event.preventDefault();
1136
+ const prompt = input.trim();
1137
+ if (!prompt || isBusy) {
1138
+ return;
1139
+ }
1140
+
1141
+ setErrorMessage("");
1142
+ setIsBusy(true);
1143
+ setIsAwaitingFirstToken(true);
1144
+
1145
+ const userMessage: ChatMessage = {
1146
+ id: createMessageId(),
1147
+ role: "user",
1148
+ content: prompt,
1149
+ };
1150
+ const assistantId = createMessageId();
1151
+ const nextConversation = [...messages, userMessage];
1152
+
1153
+ setInput("");
1154
+ setMessages(nextConversation);
1155
+
1156
+ activeRequestAbortRef.current?.abort();
1157
+ const abortController = new AbortController();
1158
+ activeRequestAbortRef.current = abortController;
1159
+
1160
+ try {
1161
+ const requestHeaders: Record<string, string> = {
1162
+ "Content-Type": "application/json",
1163
+ };
1164
+
1165
+ if (devProxyToken && devProxyToken.trim().length > 0) {
1166
+ requestHeaders["x-ask-ai-dev-token"] = devProxyToken;
1167
+ }
1168
+
1169
+ const response = await fetch(apiPath, {
1170
+ method: "POST",
1171
+ headers: requestHeaders,
1172
+ body: JSON.stringify({
1173
+ messages: nextConversation.map((message) => ({
1174
+ role: message.role,
1175
+ content: message.content,
1176
+ })),
1177
+ }),
1178
+ signal: abortController.signal,
1179
+ });
1180
+
1181
+ if (!response.ok) {
1182
+ const rawBody = await response.text();
1183
+ const detail = extractErrorMessage(rawBody);
1184
+ throw new Error(
1185
+ detail || `Request failed with status ${response.status}`,
1186
+ );
1187
+ }
1188
+
1189
+ if (!response.body) {
1190
+ throw new Error("Response body is empty");
1191
+ }
1192
+
1193
+ const reader = response.body.getReader();
1194
+ const decoder = new TextDecoder();
1195
+ let buffer = "";
1196
+ let streamDone = false;
1197
+ let hasReceivedFirstTextDelta = false;
1198
+
1199
+ while (!streamDone) {
1200
+ const { done, value } = await reader.read();
1201
+ if (done) {
1202
+ break;
1203
+ }
1204
+
1205
+ buffer += decoder.decode(value, { stream: true });
1206
+
1207
+ while (true) {
1208
+ const eventBreakIndex = buffer.indexOf("\n\n");
1209
+ if (eventBreakIndex === -1) {
1210
+ break;
1211
+ }
1212
+
1213
+ const rawEvent = buffer.slice(0, eventBreakIndex);
1214
+ buffer = buffer.slice(eventBreakIndex + 2);
1215
+
1216
+ const dataPayload = rawEvent
1217
+ .split("\n")
1218
+ .filter((line) => line.startsWith("data:"))
1219
+ .map((line) => line.slice(5).trimStart())
1220
+ .join("\n");
1221
+
1222
+ if (!dataPayload) {
1223
+ continue;
1224
+ }
1225
+
1226
+ if (dataPayload === "[DONE]") {
1227
+ streamDone = true;
1228
+ break;
1229
+ }
1230
+
1231
+ let parsed: AskAiStreamEvent;
1232
+ try {
1233
+ parsed = JSON.parse(dataPayload) as AskAiStreamEvent;
1234
+ } catch {
1235
+ continue;
1236
+ }
1237
+
1238
+ if (
1239
+ parsed.type === "text-delta" &&
1240
+ typeof parsed.delta === "string" &&
1241
+ parsed.delta.length > 0
1242
+ ) {
1243
+ const deltaText = parsed.delta;
1244
+
1245
+ if (!hasReceivedFirstTextDelta) {
1246
+ hasReceivedFirstTextDelta = true;
1247
+ setIsAwaitingFirstToken(false);
1248
+ }
1249
+
1250
+ setMessages((previous) => {
1251
+ const existingAssistantMessage = previous.find(
1252
+ (message) => message.id === assistantId,
1253
+ );
1254
+
1255
+ if (!existingAssistantMessage) {
1256
+ return [
1257
+ ...previous,
1258
+ {
1259
+ id: assistantId,
1260
+ role: "assistant",
1261
+ content: deltaText,
1262
+ },
1263
+ ];
1264
+ }
1265
+
1266
+ return previous.map((message) =>
1267
+ message.id === assistantId
1268
+ ? {
1269
+ ...message,
1270
+ content: `${message.content}${deltaText}`,
1271
+ }
1272
+ : message,
1273
+ );
1274
+ });
1275
+ }
1276
+ }
1277
+ }
1278
+ } catch (error) {
1279
+ if (abortController.signal.aborted) {
1280
+ return;
1281
+ }
1282
+
1283
+ const resolvedError =
1284
+ error instanceof Error ? error.message : String(error);
1285
+ setErrorMessage(resolvedError || "Failed to get response.");
1286
+ } finally {
1287
+ if (activeRequestAbortRef.current === abortController) {
1288
+ activeRequestAbortRef.current = null;
1289
+ }
1290
+ setIsBusy(false);
1291
+ setIsAwaitingFirstToken(false);
1292
+ }
1293
+ };
1294
+
1295
+ const handleStartNewChat = () => {
1296
+ activeRequestAbortRef.current?.abort();
1297
+ activeRequestAbortRef.current = null;
1298
+ setIsBusy(false);
1299
+ setIsAwaitingFirstToken(false);
1300
+ setErrorMessage("");
1301
+ setInput("");
1302
+ setMessages([]);
1303
+ setThreadScrollTop(0);
1304
+ threadScrollTopRef.current = 0;
1305
+ const viewport = desktopScrollViewportRef.current;
1306
+ if (viewport) {
1307
+ viewport.scrollTop = 0;
1308
+ }
1309
+ };
1310
+
1311
+ const handleChatInputKeyDown = (event: ChatInputKeyEvent) => {
1312
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
1313
+ return;
1314
+ }
1315
+
1316
+ event.preventDefault();
1317
+ const form = event.currentTarget.form;
1318
+ if (form) {
1319
+ form.requestSubmit();
1320
+ }
1321
+ };
1322
+
1323
+ const handleRenderedMarkdownClick = (
1324
+ event: JSX.TargetedMouseEvent<HTMLDivElement>,
1325
+ ) => {
1326
+ const targetElement = event.target instanceof Element ? event.target : null;
1327
+ const copyButton = targetElement?.closest(
1328
+ ".ask-ai-copy-code-button",
1329
+ ) as HTMLButtonElement | null;
1330
+
1331
+ if (!copyButton) {
1332
+ return;
1333
+ }
1334
+
1335
+ event.preventDefault();
1336
+ event.stopPropagation();
1337
+
1338
+ if (copyButton.dataset.copying === "true") {
1339
+ return;
1340
+ }
1341
+
1342
+ const preElement = copyButton.closest("pre");
1343
+ const codeElement = preElement?.querySelector("code");
1344
+ if (!codeElement) {
1345
+ return;
1346
+ }
1347
+
1348
+ const codeText = codeElement.textContent ?? "";
1349
+ copyButton.dataset.copying = "true";
1350
+
1351
+ void (async () => {
1352
+ const didCopy = await copyToClipboard(codeText);
1353
+ if (didCopy) {
1354
+ copyButton.dataset.copied = "true";
1355
+ } else {
1356
+ delete copyButton.dataset.copied;
1357
+ }
1358
+
1359
+ window.setTimeout(() => {
1360
+ delete copyButton.dataset.copied;
1361
+ delete copyButton.dataset.copying;
1362
+ }, 1200);
1363
+ })();
1364
+ };
1365
+
1366
+ const renderPanelContent = (mobile: boolean) => (
1367
+ <>
1368
+ <div
1369
+ className={`flex items-center justify-between border-b border-border px-4 ${mobile ? "py-3" : "py-2"}`}
1370
+ >
1371
+ <button
1372
+ type="button"
1373
+ className="inline-flex items-center gap-1.5 rounded-md -ml-2 px-2 py-1 text-[13px] text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
1374
+ onClick={handleStartNewChat}
1375
+ disabled={isBusy}
1376
+ >
1377
+ <Icon
1378
+ icon="lucide:messages-square"
1379
+ className="size-3.5 -ml-px"
1380
+ aria-hidden="true"
1381
+ />
1382
+ New chat
1383
+ </button>
1384
+ <button
1385
+ type="button"
1386
+ className="inline-flex items-center justify-center gap-1 rounded-full -mr-2 -my-px size-8 text-[13px] text-neutral-500 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
1387
+ onClick={() => handleOpenChange(false)}
1388
+ >
1389
+ <Icon icon="lucide:x" className="size-4.5" aria-hidden="true" />
1390
+ </button>
1391
+ </div>
1392
+
1393
+ {isChatAvailable ? (
1394
+ <>
1395
+ <div
1396
+ className={
1397
+ mobile
1398
+ ? `flex-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 py-3 ${messages.length > 0 && "pb-60"} mb-10 space-y-6`
1399
+ : `flex-1 overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 py-3 ${messages.length > 0 && "pb-60"} mb-10 space-y-6`
1400
+ }
1401
+ ref={desktopScrollViewportRef}
1402
+ onScroll={handleThreadViewportScroll}
1403
+ >
1404
+ {messages.length === 0 ? (
1405
+ <div class="flex items-center justify-center h-full">
1406
+ <p className="text-3xl font-[450]">How can I help?</p>
1407
+ </div>
1408
+ ) : null}
1409
+
1410
+ {messages.map((message) => {
1411
+ if (
1412
+ message.role === "assistant" &&
1413
+ message.content.length === 0
1414
+ ) {
1415
+ return null;
1416
+ }
1417
+ const isUser = message.role === "user";
1418
+ return (
1419
+ <div
1420
+ key={message.id}
1421
+ className={isUser ? "text-right" : "text-left"}
1422
+ >
1423
+ <div
1424
+ className={[
1425
+ "ask-ai-markdown prose-rules max-w-full min-w-0 wrap-break-word prose-pre:bg-neutral-50! prose-pre:shadow-xs prose-pre:text-neutral-700! prose-pre:border prose-pre:border-neutral-200 prose-pre:rounded-xl!",
1426
+ isUser
1427
+ ? "inline-block px-3 py-2 rounded-3xl rounded-br-sm bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 *:text-left"
1428
+ : "block w-full bg-transparent text-neutral-900 dark:text-neutral-100",
1429
+ ].join(" ")}
1430
+ onClick={handleRenderedMarkdownClick}
1431
+ dangerouslySetInnerHTML={{
1432
+ __html: renderMarkdownToHtml(message.content),
1433
+ }}
1434
+ ></div>
1435
+ </div>
1436
+ );
1437
+ })}
1438
+
1439
+ {isBusy && isAwaitingFirstToken ? (
1440
+ <div className="text-left">
1441
+ <span
1442
+ className="inline-block text-sm text-transparent bg-clip-text bg-[linear-gradient(110deg,var(--color-neutral-400)_50%,var(--color-neutral-300)_60%,var(--color-neutral-400)_70%)] dark:bg-[linear-gradient(110deg,#a3a3a3_30%,#ffffff_45%,#a3a3a3_60%)] bg-[length:200%_100%]"
1443
+ style={{
1444
+ animation: "ask-ai-thinking-shimmer 3.4s linear infinite",
1445
+ }}
1446
+ >
1447
+ Thinking
1448
+ </span>
1449
+ </div>
1450
+ ) : null}
1451
+
1452
+ {errorMessage ? (
1453
+ <p className="text-sm text-red-600 dark:text-red-300">
1454
+ {errorMessage}
1455
+ </p>
1456
+ ) : null}
1457
+ </div>
1458
+
1459
+ <form
1460
+ className={
1461
+ mobile
1462
+ ? "absolute z-10 bottom-0 inset-x-0 px-3 pb-3"
1463
+ : "absolute z-10 bottom-0 inset-x-0 px-3 pb-3"
1464
+ }
1465
+ onSubmit={(event) => {
1466
+ void handleSubmit(event);
1467
+ }}
1468
+ >
1469
+ <div className="bg-white flex gap-2 border border-border rounded-[24px]">
1470
+ <textarea
1471
+ ref={chatInputTextareaRef}
1472
+ value={input}
1473
+ onInput={(event) => {
1474
+ setInput((event.target as HTMLTextAreaElement).value);
1475
+ }}
1476
+ onKeyDown={handleChatInputKeyDown}
1477
+ placeholder="Ask a question..."
1478
+ className="my-auto min-w-0 flex-1 bg-transparent pl-4 py-3 text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-400 leading-5 resize-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1479
+ disabled={isBusy}
1480
+ rows={1}
1481
+ />
1482
+ <div class="p-1 pl-0 mt-auto">
1483
+ <button
1484
+ type="submit"
1485
+ className="flex items-center justify-center rounded-full bg-neutral-900 size-10 disabled:opacity-90 dark:bg-white dark:text-neutral-900 cursor-pointer"
1486
+ disabled={isBusy || input.trim().length === 0}
1487
+ >
1488
+ <Icon
1489
+ icon="lucide:arrow-up"
1490
+ className="size-5 invert dark:invert-0"
1491
+ aria-hidden="true"
1492
+ />
1493
+ </button>
1494
+ </div>
1495
+ </div>
1496
+ </form>
1497
+ </>
1498
+ ) : (
1499
+ <div className={mobile ? "flex-1 px-4 py-4" : "px-4 py-4"}>
1500
+ <p className="text-sm text-neutral-600 dark:text-neutral-300">
1501
+ {unavailableMessage}
1502
+ </p>
1503
+ </div>
1504
+ )}
1505
+ </>
1506
+ );
1507
+
1508
+ const desktopMaxHeight = getDesktopMaxHeightPx();
1509
+ const desktopPanelWidth = clamp(
1510
+ desktopSize.width,
1511
+ DESKTOP_MIN_WIDTH_PX,
1512
+ DESKTOP_MAX_WIDTH_PX,
1513
+ );
1514
+ const desktopPanelHeight = clamp(
1515
+ desktopSize.height,
1516
+ DESKTOP_MIN_HEIGHT_PX,
1517
+ desktopMaxHeight,
1518
+ );
1519
+
1520
+ return (
1521
+ <>
1522
+ <style>{`
1523
+ @keyframes ask-ai-thinking-shimmer {
1524
+ 0% { background-position: 200% 0; }
1525
+ 100% { background-position: -200% 0; }
1526
+ }
1527
+
1528
+ .prose-rules pre[class*="language-"],
1529
+ .prose-rules code[class*="language-"] {
1530
+ font-family: var(--font-mono);
1531
+ font-size: 13px;
1532
+ line-height: inherit;
1533
+ }
1534
+
1535
+ .ask-ai-markdown pre {
1536
+ position: relative;
1537
+ width: 100%;
1538
+ max-width: 100%;
1539
+ min-width: 0;
1540
+ box-sizing: border-box;
1541
+ padding-top: 2.25rem;
1542
+ overflow-x: auto;
1543
+ overflow-y: hidden;
1544
+ white-space: pre;
1545
+ -webkit-overflow-scrolling: touch;
1546
+ scrollbar-width: thin;
1547
+ scrollbar-color: var(--color-neutral-300) var(--color-neutral-100);
1548
+ }
1549
+
1550
+ .ask-ai-markdown pre[data-language]::before {
1551
+ content: "";
1552
+ position: absolute;
1553
+ top: 0.52rem;
1554
+ left: 0.75rem;
1555
+ width: 16px;
1556
+ height: 16px;
1557
+ background-color: var(--color-neutral-500);
1558
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='m9.4 16.6-4.6-4.6 4.6-4.6L8 6l-6 6 6 6zM14.6 16.6 19.2 12l-4.6-4.6L16 6l6 6-6 6z'/%3E%3C/svg%3E");
1559
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='m9.4 16.6-4.6-4.6 4.6-4.6L8 6l-6 6 6 6zM14.6 16.6 19.2 12l-4.6-4.6L16 6l6 6-6 6z'/%3E%3C/svg%3E");
1560
+ -webkit-mask-repeat: no-repeat;
1561
+ mask-repeat: no-repeat;
1562
+ -webkit-mask-position: center;
1563
+ mask-position: center;
1564
+ -webkit-mask-size: contain;
1565
+ mask-size: contain;
1566
+ pointer-events: none;
1567
+ user-select: none;
1568
+ }
1569
+
1570
+ .ask-ai-markdown pre[data-language]::after {
1571
+ content: attr(data-language);
1572
+ position: absolute;
1573
+ top: 0.6rem;
1574
+ left: 2.2rem;
1575
+ font-family: var(--font-sans);
1576
+ font-size: 14px;
1577
+ line-height: 1;
1578
+ letter-spacing: 0.01em;
1579
+ color: var(--color-neutral-500);
1580
+ pointer-events: none;
1581
+ user-select: none;
1582
+ }
1583
+
1584
+ .ask-ai-markdown .ask-ai-copy-code-button {
1585
+ position: absolute;
1586
+ top: 0.3rem;
1587
+ right: 0.46rem;
1588
+ z-index: 2;
1589
+ display: inline-flex;
1590
+ align-items: center;
1591
+ justify-content: center;
1592
+ width: 1.75rem;
1593
+ height: 1.75rem;
1594
+ border: 0;
1595
+ background: color-mix(
1596
+ in oklab,
1597
+ var(--color-neutral-50) 80%,
1598
+ transparent
1599
+ );
1600
+ backdrop-filter: blur(4px);
1601
+ color: color-mix(
1602
+ in oklab,
1603
+ var(--color-neutral-500) 80%,
1604
+ transparent
1605
+ );
1606
+ border-radius: 8px;
1607
+ cursor: pointer;
1608
+ transition: background-color 120ms ease, color 120ms ease,
1609
+ transform 120ms ease;
1610
+ }
1611
+
1612
+ .ask-ai-markdown .ask-ai-copy-code-button:hover {
1613
+ background: var(--color-neutral-100);
1614
+ color: var(--color-neutral-700);
1615
+ }
1616
+
1617
+ .ask-ai-markdown .ask-ai-copy-code-button:active {
1618
+ transform: translateY(0.5px);
1619
+ }
1620
+
1621
+ .ask-ai-markdown .ask-ai-copy-code-button:focus-visible {
1622
+ outline: 2px solid var(--color-neutral-300);
1623
+ outline-offset: 1px;
1624
+ }
1625
+
1626
+ .ask-ai-markdown .ask-ai-copy-icon,
1627
+ .ask-ai-markdown .ask-ai-copy-check-icon {
1628
+ position: absolute;
1629
+ width: 14px;
1630
+ height: 14px;
1631
+ transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
1632
+ opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
1633
+ will-change: transform, opacity;
1634
+ }
1635
+
1636
+ .ask-ai-markdown .ask-ai-copy-icon {
1637
+ opacity: 1;
1638
+ transform: scale(1) rotate(0deg);
1639
+ background-color: currentColor;
1640
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
1641
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
1642
+ -webkit-mask-repeat: no-repeat;
1643
+ mask-repeat: no-repeat;
1644
+ -webkit-mask-position: center;
1645
+ mask-position: center;
1646
+ -webkit-mask-size: contain;
1647
+ mask-size: contain;
1648
+ }
1649
+
1650
+ .ask-ai-markdown .ask-ai-copy-check-icon {
1651
+ opacity: 0;
1652
+ transform: scale(0.25) rotate(6deg);
1653
+ background-color: color-mix(
1654
+ in oklab,
1655
+ var(--color-green-700) 80%,
1656
+ transparent
1657
+ );
1658
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
1659
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
1660
+ -webkit-mask-repeat: no-repeat;
1661
+ mask-repeat: no-repeat;
1662
+ -webkit-mask-position: center;
1663
+ mask-position: center;
1664
+ -webkit-mask-size: contain;
1665
+ mask-size: contain;
1666
+ }
1667
+
1668
+ .ask-ai-markdown .ask-ai-copy-code-button[data-copied="true"] .ask-ai-copy-icon {
1669
+ opacity: 0;
1670
+ transform: scale(0.5) rotate(-6deg);
1671
+ }
1672
+
1673
+ .ask-ai-markdown .ask-ai-copy-code-button[data-copied="true"] .ask-ai-copy-check-icon {
1674
+ opacity: 1;
1675
+ transform: scale(1.1) rotate(0deg);
1676
+ }
1677
+
1678
+ .ask-ai-markdown pre > code {
1679
+ display: block;
1680
+ min-width: max-content;
1681
+ white-space: inherit;
1682
+ word-break: normal;
1683
+ overflow-wrap: normal;
1684
+ }
1685
+
1686
+ .ask-ai-markdown pre::-webkit-scrollbar {
1687
+ height: 10px;
1688
+ }
1689
+
1690
+ .ask-ai-markdown pre::-webkit-scrollbar-track {
1691
+ background: var(--color-neutral-100);
1692
+ border-radius: 999px;
1693
+ }
1694
+
1695
+ .ask-ai-markdown pre::-webkit-scrollbar-thumb {
1696
+ background: var(--color-neutral-300);
1697
+ border-radius: 999px;
1698
+ border: 2px solid var(--color-neutral-100);
1699
+ }
1700
+
1701
+ .ask-ai-markdown pre::-webkit-scrollbar-thumb:hover {
1702
+ background: var(--color-neutral-400);
1703
+ }
1704
+ `}</style>
1705
+ {!isOpen ? (
1706
+ <div className="fixed bottom-5 right-6 size-12 z-40 bg-white hover:scale-105 transition duration-300 ease-in-out">
1707
+ <button
1708
+ type="button"
1709
+ className="w-full h-full inline-flex items-center justify-center gap-2 rounded-full bg-linear-to-b from-neutral-900/85 to-neutral-900 shadow-xl dark:bg-white cursor-pointer"
1710
+ onClick={() => handleOpenChange(true)}
1711
+ data-pagefind-ignore
1712
+ >
1713
+ <img
1714
+ src={sparkleIcon}
1715
+ alt=""
1716
+ aria-hidden="true"
1717
+ className="size-4 invert dark:invert-0"
1718
+ />
1719
+ </button>
1720
+ </div>
1721
+ ) : null}
1722
+
1723
+ {isMobile ? (
1724
+ <Drawer.Root
1725
+ open={isOpen}
1726
+ onOpenChange={handleOpenChange}
1727
+ disablePreventScroll={true}
1728
+ >
1729
+ <Drawer.Portal>
1730
+ <Drawer.Overlay className="fixed inset-0 z-50 bg-black/40" />
1731
+ <Drawer.Content
1732
+ className="fixed inset-x-0 bottom-0 z-50 flex h-[85dvh] max-h-[85dvh] flex-col rounded-t-2xl border border-border bg-background shadow-xl will-change-transform"
1733
+ data-pagefind-ignore
1734
+ >
1735
+ <div className="absolute left-1/2 -translate-x-1/2 mt-2 h-1.5 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
1736
+ {renderPanelContent(true)}
1737
+ </Drawer.Content>
1738
+ </Drawer.Portal>
1739
+ </Drawer.Root>
1740
+ ) : isOpen ? (
1741
+ <div
1742
+ className="fixed bottom-4 right-5 z-50"
1743
+ data-pagefind-ignore
1744
+ onWheelCapture={handleDesktopWheelCapture}
1745
+ >
1746
+ <div
1747
+ className="relative flex flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-[0_6px_20px_rgba(0,0,0,0.2)] shadow-neutral-300. shadow-black/15"
1748
+ style={{
1749
+ width: `${desktopPanelWidth}px`,
1750
+ height: `${desktopPanelHeight}px`,
1751
+ }}
1752
+ >
1753
+ <div
1754
+ className="absolute left-0 top-3 bottom-3 z-20 w-2 cursor-ew-resize touch-none"
1755
+ onPointerDown={beginDesktopResize("left")}
1756
+ />
1757
+ <div
1758
+ className="absolute left-3 right-3 top-0 z-20 h-2 cursor-ns-resize touch-none"
1759
+ onPointerDown={beginDesktopResize("top")}
1760
+ />
1761
+ <div
1762
+ className="absolute left-0 top-0 z-30 h-3 w-3 cursor-nwse-resize touch-none"
1763
+ onPointerDown={beginDesktopResize("corner")}
1764
+ />
1765
+ {renderPanelContent(false)}
1766
+ </div>
1767
+ </div>
1768
+ ) : null}
1769
+ </>
1770
+ );
1771
+ }