@usereq/widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +166 -0
  2. package/dist/widget.css +2 -0
  3. package/dist/widget.js +3569 -0
  4. package/package.json +98 -0
  5. package/src/chat-widget/chat-widget-appearance.ts +55 -0
  6. package/src/chat-widget/chat-widget-defaults.tsx +45 -0
  7. package/src/chat-widget/components/chat-widget-frame.tsx +56 -0
  8. package/src/chat-widget/components/chat-widget-layout.ts +6 -0
  9. package/src/chat-widget/components/chat-widget-primitives.tsx +720 -0
  10. package/src/chat-widget/components/confirmation.tsx +155 -0
  11. package/src/chat-widget/components/conversation.tsx +110 -0
  12. package/src/chat-widget/components/message.tsx +325 -0
  13. package/src/chat-widget/index.ts +9 -0
  14. package/src/chat-widget/stop-confirmation.ts +288 -0
  15. package/src/chat-widget/styles/chat-widget-box.tsx +258 -0
  16. package/src/chat-widget/styles/chat-widget-bubble.tsx +238 -0
  17. package/src/chat-widget/styles/chat-widget-chatbar.tsx +248 -0
  18. package/src/custom-element/agent-widget-element.tsx +584 -0
  19. package/src/embed.ts +8 -0
  20. package/src/index.ts +10 -0
  21. package/src/register.ts +15 -0
  22. package/src/renderer/index.ts +6 -0
  23. package/src/renderer/widget-runtime.tsx +205 -0
  24. package/src/runtime/analytics.ts +327 -0
  25. package/src/runtime/api-origin.ts +29 -0
  26. package/src/runtime/api.ts +151 -0
  27. package/src/runtime/bootstrap.ts +309 -0
  28. package/src/runtime/messages.ts +12 -0
  29. package/src/runtime/session-storage.ts +32 -0
  30. package/src/runtime/token-renewal.ts +13 -0
  31. package/src/runtime/trigger-rule.ts +182 -0
  32. package/src/shared/analytics.ts +54 -0
  33. package/src/shared/shadow-css.ts +15 -0
  34. package/src/shared/shadow-theme.ts +85 -0
  35. package/src/shared/stop-confirmation.ts +20 -0
  36. package/src/shared/widget-config.ts +104 -0
  37. package/src/styles/widget.css.ts +27 -0
  38. package/src/types.ts +85 -0
  39. package/src/vite-env.d.ts +4 -0
@@ -0,0 +1,720 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { motion } from "motion/react";
5
+ import { cn } from "@usereq/ui/lib/utils";
6
+ import {
7
+ Avatar,
8
+ AvatarFallback,
9
+ AvatarImage,
10
+ } from "@usereq/ui/components/avatar";
11
+ import { Button } from "@usereq/ui/components/button";
12
+ import { Textarea } from "@usereq/ui/components/textarea";
13
+ import { Separator } from "@usereq/ui/components/separator";
14
+ import {
15
+ ArrowUpRight,
16
+ MessageCircleX,
17
+ Minimize2,
18
+ MessageSquareText,
19
+ SendIcon,
20
+ Square,
21
+ } from "lucide-react";
22
+ import { Dialog, DialogContent, DialogTitle } from "@usereq/ui/components/dialog";
23
+ import {
24
+ DEFAULT_COMPOSER_PLACEHOLDER,
25
+ DEFAULT_EMPTY_DESCRIPTION,
26
+ DEFAULT_EMPTY_TITLE,
27
+ } from "../chat-widget-defaults";
28
+ import {
29
+ getChatWidgetAvatarStyle,
30
+ getChatWidgetGradientStyle,
31
+ type ChatWidgetAppearance,
32
+ } from "../chat-widget-appearance";
33
+ import {
34
+ Conversation,
35
+ ConversationContent,
36
+ ConversationEmptyState,
37
+ ConversationScrollButton,
38
+ } from "./conversation";
39
+ import { foldStopConfirmationMessages } from "../stop-confirmation";
40
+ import { getChatWidgetMessagesLayoutClasses } from "./chat-widget-layout";
41
+ import { Message, MessageContent, MessageResponse } from "./message";
42
+
43
+ const panelTransition = {
44
+ type: "spring" as const,
45
+ stiffness: 400,
46
+ damping: 35,
47
+ };
48
+ const CHAT_WIDGET_FULLSCREEN_BREAKPOINT = 1024;
49
+
50
+ export type ChatWidgetMessageAction = {
51
+ label: string;
52
+ onClick: () => void;
53
+ variant?: "default" | "destructive" | "outline" | "ghost";
54
+ };
55
+
56
+ export type ChatWidgetPreviewMessage = {
57
+ id: string;
58
+ role: "assistant" | "user";
59
+ content: string;
60
+ /** Inline action buttons rendered below the bubble (e.g. for stop-conversation confirmation). */
61
+ actions?: ChatWidgetMessageAction[];
62
+ };
63
+
64
+ /** Shared data props for all chat widgets — pass from parent / fetch from DB */
65
+ export type ChatWidgetDataProps = {
66
+ /** Agent or widget title (header name) */
67
+ agentName?: string;
68
+ /** Agent avatar URL */
69
+ agentAvatarUrl?: string | null;
70
+ /** Shared visual appearance knobs used by both dashboard preview and public embed. */
71
+ appearance?: ChatWidgetAppearance;
72
+ /** Current runtime status for the widget shell. */
73
+ status?: "idle" | "booting" | "ready" | "sending" | "error";
74
+ /** Static welcome line (e.g. from DB). */
75
+ welcomeMessage?: string | null;
76
+ /** Rotation of greeting strings used by closed-state spotlight surfaces. */
77
+ spotlightMessages?: string[];
78
+ /** Quick-reply suggestions shown when the widget is open and empty. */
79
+ suggestions?: string[];
80
+ /** Chat messages (conversation history) */
81
+ messages?: ChatWidgetPreviewMessage[];
82
+ /** Footer "Powered by" text */
83
+ poweredBy?: string;
84
+ /** Header status line (e.g. "Online") */
85
+ headerStatus?: string;
86
+ /** Custom error message shown in the empty state when widget bootstrap fails. */
87
+ errorMessage?: string | null;
88
+ /** When provided, composer is controlled and sends messages (for test chatting). */
89
+ onSendMessage?: (content: string) => void;
90
+ /** When true, show typing indicator (pending / data fetching). */
91
+ isTyping?: boolean;
92
+ /** Disable composer interaction while the widget is booting or sending. */
93
+ sendDisabled?: boolean;
94
+ /** When provided, show stop-conversation icon in panel header (e.g. playground reset). */
95
+ onStopConversation?: () => void;
96
+ /** When true, disables the stop-conversation control. */
97
+ stopDisabled?: boolean;
98
+ /** Called when a stop-confirmation card receives Yes/No. */
99
+ onConfirmationDecision?: (
100
+ confirmationId: string,
101
+ accepted: boolean,
102
+ ) => void | Promise<void>;
103
+ /** When provided, clicking a suggestion calls back with its text. */
104
+ onSuggestion?: (value: string) => void;
105
+ /** When provided and messages are empty, show this action in the empty state (e.g. "Start conversation"). */
106
+ emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
107
+ /** Called when a link inside an assistant message is clicked. */
108
+ onLinkClick?: (linkUrl: string) => void;
109
+ /** Called when the launcher or closed-state open action is clicked. */
110
+ onLauncherClick?: () => void;
111
+ };
112
+
113
+ const TYPING_SPEED_MS = 40;
114
+ const PAUSE_AFTER_MESSAGE_MS = 2000;
115
+
116
+ /** Returns currently displayed text for typing effect; rotates through messages. */
117
+ export function useSpotlightTyping(
118
+ messages: string[],
119
+ options?: { typingSpeedMs?: number; pauseAfterMs?: number },
120
+ ) {
121
+ const typingSpeed = options?.typingSpeedMs ?? TYPING_SPEED_MS;
122
+ const pauseAfter = options?.pauseAfterMs ?? PAUSE_AFTER_MESSAGE_MS;
123
+ const messagesKey = messages.join("\u0000");
124
+ const [messageIndex, setMessageIndex] = React.useState(0);
125
+ const [charIndex, setCharIndex] = React.useState(() =>
126
+ messages.length > 0 ? 1 : 0,
127
+ );
128
+ const [phase, setPhase] = React.useState<"typing" | "paused">("typing");
129
+
130
+ const currentMessage =
131
+ messages.length > 0 ? messages[messageIndex % messages.length]! : "";
132
+ const displayText = currentMessage.slice(0, charIndex);
133
+
134
+ React.useEffect(() => {
135
+ if (messages.length === 0) {
136
+ setMessageIndex(0);
137
+ setCharIndex(0);
138
+ setPhase("typing");
139
+ return;
140
+ }
141
+
142
+ setMessageIndex(0);
143
+ setCharIndex(1);
144
+ setPhase("typing");
145
+ }, [messagesKey, messages.length]);
146
+
147
+ React.useEffect(() => {
148
+ if (messages.length === 0) return;
149
+
150
+ if (phase === "paused") {
151
+ const t = setTimeout(() => {
152
+ setMessageIndex((i) => (i + 1) % messages.length);
153
+ setCharIndex(1);
154
+ setPhase("typing");
155
+ }, pauseAfter);
156
+ return () => clearTimeout(t);
157
+ }
158
+
159
+ if (charIndex < currentMessage.length) {
160
+ const t = setTimeout(() => setCharIndex((c) => c + 1), typingSpeed);
161
+ return () => clearTimeout(t);
162
+ }
163
+
164
+ setPhase("paused");
165
+ return undefined;
166
+ }, [
167
+ messages.length,
168
+ messageIndex,
169
+ charIndex,
170
+ currentMessage.length,
171
+ phase,
172
+ typingSpeed,
173
+ pauseAfter,
174
+ ]);
175
+
176
+ return displayText;
177
+ }
178
+
179
+ /**
180
+ * Keeps the last non-empty spotlight list stable across transient empty renders.
181
+ * This keeps closed-state spotlight copy stable when the configured spotlight
182
+ * array is momentarily cleared during rerenders.
183
+ */
184
+ export function useStableSpotlightMessages(messages: string[]) {
185
+ const lastNonEmptyMessagesRef = React.useRef<string[] | null>(null);
186
+
187
+ React.useEffect(() => {
188
+ if (messages.length > 0) {
189
+ lastNonEmptyMessagesRef.current = messages;
190
+ }
191
+ }, [messages]);
192
+
193
+ return messages.length > 0
194
+ ? messages
195
+ : lastNonEmptyMessagesRef.current ?? [];
196
+ }
197
+
198
+ /** Rotating spotlight index for fade (Bubble). */
199
+ export function useSpotlightFade(messages: string[], intervalMs = 3000) {
200
+ const [index, setIndex] = React.useState(0);
201
+ React.useEffect(() => {
202
+ if (messages.length <= 1) return;
203
+ const id = setInterval(() => {
204
+ setIndex((i) => (i + 1) % messages.length);
205
+ }, intervalMs);
206
+ return () => clearInterval(id);
207
+ }, [messages.length, intervalMs]);
208
+ return messages.length > 0 ? messages[index % messages.length]! : "";
209
+ }
210
+
211
+ export function ChatWidgetHeader({
212
+ agentName = "Assistant",
213
+ agentAvatarUrl,
214
+ appearance,
215
+ statusText = "Online",
216
+ className,
217
+ onStopConversation,
218
+ stopDisabled = false,
219
+ onClose,
220
+ }: {
221
+ agentName?: string;
222
+ agentAvatarUrl?: string | null;
223
+ appearance?: ChatWidgetAppearance;
224
+ statusText?: string;
225
+ className?: string;
226
+ /** When set, shows a stop-conversation icon button. */
227
+ onStopConversation?: () => void;
228
+ /** When true, disables the stop-conversation button. */
229
+ stopDisabled?: boolean;
230
+ /** When set, shows a close/collapse icon button. */
231
+ onClose?: () => void;
232
+ }) {
233
+ const hasActions = onStopConversation != null || onClose != null;
234
+ return (
235
+ <div className={cn("flex items-center gap-2 w-full", className)}>
236
+ <div className="flex min-w-0 flex-1 items-center gap-3">
237
+ <Avatar
238
+ size="lg"
239
+ style={
240
+ agentAvatarUrl ? undefined : getChatWidgetAvatarStyle(appearance)
241
+ }
242
+ >
243
+ {agentAvatarUrl ? (
244
+ <AvatarImage src={agentAvatarUrl} alt={agentName} />
245
+ ) : null}
246
+ <AvatarFallback
247
+ className={cn(appearance ? "bg-transparent text-white" : undefined)}
248
+ >
249
+ {agentName.slice(0, 2).toUpperCase()}
250
+ </AvatarFallback>
251
+ </Avatar>
252
+ <div className="min-w-0">
253
+ <div className="truncate text-sm font-semibold">{agentName}</div>
254
+ <div className="text-xs text-muted-foreground">{statusText}</div>
255
+ </div>
256
+ </div>
257
+ {hasActions && (
258
+ <div className="ml-auto flex shrink-0 items-center gap-1">
259
+ {onStopConversation != null && (
260
+ <Button
261
+ variant="ghost"
262
+ size="icon"
263
+ aria-label="Stop conversation"
264
+ title="Stop conversation"
265
+ disabled={stopDisabled}
266
+ onClick={onStopConversation}
267
+ >
268
+ <MessageCircleX />
269
+ </Button>
270
+ )}
271
+ {onClose != null && (
272
+ <Button
273
+ variant="ghost"
274
+ size="icon"
275
+ aria-label="Close"
276
+ title="Close"
277
+ onClick={onClose}
278
+ >
279
+ <Minimize2 />
280
+ </Button>
281
+ )}
282
+ </div>
283
+ )}
284
+ </div>
285
+ );
286
+ }
287
+
288
+ /** Animated typing indicator (bouncing dots) for assistant "thinking" state. */
289
+ export function ChatWidgetTyping({ className }: { className?: string }) {
290
+ return (
291
+ <div className="w-full flex">
292
+ <div
293
+ className={cn(
294
+ "max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed",
295
+ "bg-muted text-foreground",
296
+ className,
297
+ )}
298
+ >
299
+ <div className="flex items-center gap-1 py-0.5" aria-label="Thinking">
300
+ {[0, 1, 2].map((i) => (
301
+ <motion.span
302
+ key={i}
303
+ className="size-1.5 rounded-full bg-current opacity-60"
304
+ animate={{ y: [0, -4, 0] }}
305
+ transition={{
306
+ duration: 0.5,
307
+ repeat: Infinity,
308
+ delay: i * 0.12,
309
+ ease: "easeInOut",
310
+ }}
311
+ />
312
+ ))}
313
+ </div>
314
+ </div>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ /** Message list with sticky-bottom behavior and optional empty action. */
320
+ export function ChatWidgetMessages({
321
+ messages,
322
+ isTyping = false,
323
+ status = "idle",
324
+ errorMessage,
325
+ appearance,
326
+ onConfirmationDecision,
327
+ onLinkClick,
328
+ className,
329
+ emptyTitle = DEFAULT_EMPTY_TITLE,
330
+ emptyDescription = DEFAULT_EMPTY_DESCRIPTION,
331
+ emptyAction,
332
+ }: {
333
+ messages: ChatWidgetPreviewMessage[];
334
+ /** When true, show typing indicator (pending / data fetching). */
335
+ isTyping?: boolean;
336
+ status?: ChatWidgetDataProps["status"];
337
+ suggestions?: string[];
338
+ errorMessage?: string | null;
339
+ appearance?: ChatWidgetAppearance;
340
+ onSuggestion?: (value: string) => void;
341
+ className?: string;
342
+ emptyTitle?: string;
343
+ emptyDescription?: string;
344
+ /** When provided and messages empty, show this button in the empty state. */
345
+ emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
346
+ onConfirmationDecision?: (
347
+ confirmationId: string,
348
+ accepted: boolean,
349
+ ) => void | Promise<void>;
350
+ onLinkClick?: (linkUrl: string) => void;
351
+ }) {
352
+ const { scrollClassName, contentClassName } =
353
+ getChatWidgetMessagesLayoutClasses();
354
+ const renderedMessages = React.useMemo(
355
+ () => foldStopConfirmationMessages(messages),
356
+ [messages],
357
+ );
358
+
359
+ return (
360
+ <Conversation className={cn("relative size-full", className)}>
361
+ <ConversationContent
362
+ scrollClassName={scrollClassName}
363
+ className={contentClassName}
364
+ >
365
+ {messages.length === 0 && !isTyping ? (
366
+ status === "error" ? (
367
+ <div className="state error">
368
+ {errorMessage ?? "Something went wrong."}
369
+ </div>
370
+ ) : (
371
+ <div className="flex min-h-full flex-1 items-center justify-center px-4 py-6">
372
+ <ConversationEmptyState className="w-full max-w-[280px]">
373
+ <div className="flex size-16 items-center justify-center rounded-2xl border border-border/70 bg-background/90 shadow-sm">
374
+ <MessageSquareText className="size-7 text-foreground" />
375
+ </div>
376
+ <div>
377
+ <p className="text-lg font-semibold leading-tight text-foreground">
378
+ {emptyTitle}
379
+ </p>
380
+ <p className="mt-2 text-sm text-muted-foreground">
381
+ {emptyDescription}
382
+ </p>
383
+ </div>
384
+ {emptyAction && (
385
+ <Button
386
+ size="lg"
387
+ disabled={emptyAction.disabled}
388
+ onClick={emptyAction.onClick}
389
+ className="rounded-full bg-black px-6 text-white hover:bg-black/90"
390
+ >
391
+ {emptyAction.label}
392
+ </Button>
393
+ )}
394
+ </ConversationEmptyState>
395
+ </div>
396
+ )
397
+ ) : (
398
+ <>
399
+ {renderedMessages.map((item) => {
400
+ if (item.type === "confirmation") {
401
+ return (
402
+ <Message key={item.message.id} from="assistant">
403
+ <div className="flex w-fit max-w-[85%] shrink-0 flex-col gap-2">
404
+ <MessageResponse
405
+ confirmation={item.latestEnvelope}
406
+ confirmationPrompt={item.envelope.prompt}
407
+ confirmationActionsDisabled={status === "sending"}
408
+ onConfirmationDecision={onConfirmationDecision}
409
+ />
410
+ </div>
411
+ </Message>
412
+ );
413
+ }
414
+
415
+ const m = item.message;
416
+ return (
417
+ <Message key={m.id} from={m.role}>
418
+ <div className="flex w-fit max-w-[85%] shrink-0 flex-col gap-2">
419
+ <MessageContent
420
+ className={cn(
421
+ "whitespace-pre-wrap",
422
+ m.role === "user"
423
+ ? "text-white"
424
+ : "bg-muted text-foreground",
425
+ )}
426
+ style={
427
+ m.role === "user"
428
+ ? getChatWidgetGradientStyle(appearance)
429
+ : undefined
430
+ }
431
+ >
432
+ {m.role === "assistant" ? (
433
+ <MessageResponse onLinkClick={onLinkClick}>
434
+ {m.content}
435
+ </MessageResponse>
436
+ ) : (
437
+ m.content
438
+ )}
439
+ </MessageContent>
440
+ {m.actions && m.actions.length > 0 && (
441
+ <div className="flex flex-wrap gap-2 px-1">
442
+ {m.actions.map((action, i) => (
443
+ <Button
444
+ key={i}
445
+ size="sm"
446
+ variant={action.variant ?? "outline"}
447
+ onClick={action.onClick}
448
+ >
449
+ {action.label}
450
+ </Button>
451
+ ))}
452
+ </div>
453
+ )}
454
+ </div>
455
+ </Message>
456
+ );
457
+ })}
458
+ {isTyping && (
459
+ <motion.div
460
+ initial={{ opacity: 0, y: 8 }}
461
+ animate={{ opacity: 1, y: 0 }}
462
+ transition={{ duration: 0.2, ease: "easeOut" }}
463
+ className="w-full"
464
+ >
465
+ <ChatWidgetTyping />
466
+ </motion.div>
467
+ )}
468
+ </>
469
+ )}
470
+ </ConversationContent>
471
+ <ConversationScrollButton />
472
+ </Conversation>
473
+ );
474
+ }
475
+
476
+ const COMPOSER_MIN_ROWS = 2;
477
+
478
+ export function ChatWidgetComposer({
479
+ placeholder = DEFAULT_COMPOSER_PLACEHOLDER,
480
+ value,
481
+ onChange,
482
+ onSubmit,
483
+ appearance,
484
+ disabled = false,
485
+ className,
486
+ }: {
487
+ placeholder?: string;
488
+ /** Controlled value (use with onChange + onSubmit for test chatting). */
489
+ value?: string;
490
+ onChange?: (value: string) => void;
491
+ /** Called on Send click or Enter (without Shift). Clears input after submit when controlled. */
492
+ onSubmit?: (value: string) => void;
493
+ appearance?: ChatWidgetAppearance;
494
+ disabled?: boolean;
495
+ className?: string;
496
+ }) {
497
+ const isControlled = value !== undefined && onChange !== undefined;
498
+ const [localValue, setLocalValue] = React.useState("");
499
+ const currentValue = isControlled ? value : localValue;
500
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
501
+
502
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
503
+ const v = e.target.value;
504
+ if (isControlled) onChange(v);
505
+ else setLocalValue(v);
506
+ };
507
+
508
+ const submit = () => {
509
+ const trimmed = currentValue.trim();
510
+ if (!trimmed || !onSubmit) return;
511
+ onSubmit(trimmed);
512
+ if (isControlled && onChange) onChange("");
513
+ else setLocalValue("");
514
+ textareaRef.current?.focus();
515
+ };
516
+
517
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
518
+ if (e.key !== "Enter") return;
519
+ if (e.shiftKey) return; // Shift+Enter = new line
520
+ e.preventDefault();
521
+ submit();
522
+ };
523
+
524
+ return (
525
+ <div className={cn("flex items-end gap-2", className)}>
526
+ <Textarea
527
+ ref={textareaRef}
528
+ placeholder={placeholder}
529
+ value={currentValue}
530
+ onChange={handleChange}
531
+ onKeyDown={handleKeyDown}
532
+ rows={COMPOSER_MIN_ROWS}
533
+ disabled={disabled}
534
+ className={cn(
535
+ "min-h-10 max-h-32 flex-1 resize-none py-2.5",
536
+ "focus-visible:ring-2",
537
+ )}
538
+ />
539
+ <Button
540
+ type="button"
541
+ size="icon"
542
+ className="h-10 w-10 shrink-0 text-white"
543
+ style={getChatWidgetGradientStyle(appearance)}
544
+ aria-label="Send"
545
+ disabled={disabled || !onSubmit}
546
+ onClick={submit}
547
+ >
548
+ <SendIcon className="size-4" />
549
+ </Button>
550
+ </div>
551
+ );
552
+ }
553
+
554
+ export function ChatWidgetPoweredBy({
555
+ text = "Powered by UserEQ",
556
+ className,
557
+ }: {
558
+ text?: string;
559
+ className?: string;
560
+ }) {
561
+ return (
562
+ <div className={cn("text-center text-xs text-muted-foreground", className)}>
563
+ {text}
564
+ </div>
565
+ );
566
+ }
567
+
568
+ export function ChatWidgetSectionDivider({
569
+ className,
570
+ }: {
571
+ className?: string;
572
+ }) {
573
+ return <Separator className={cn("my-3", className)} />;
574
+ }
575
+
576
+ /**
577
+ * Wraps panel content: Dialog (full-screen) on mobile when open, floating panel on desktop.
578
+ */
579
+ export function ChatWidgetPanel({
580
+ open,
581
+ onOpenChange,
582
+ children,
583
+ desktopClassName,
584
+ desktopStyle,
585
+ portalContainer,
586
+ }: {
587
+ open: boolean;
588
+ onOpenChange?: (open: boolean) => void;
589
+ children: React.ReactNode;
590
+ desktopClassName?: string;
591
+ desktopStyle?: React.CSSProperties;
592
+ portalContainer?: HTMLElement | DocumentFragment | null;
593
+ }) {
594
+ const isMobile = useIsChatWidgetFullscreenViewport();
595
+ const lockedFullscreenHeight = useLockedFullscreenViewportHeight(
596
+ open,
597
+ isMobile,
598
+ );
599
+
600
+ if (isMobile) {
601
+ return (
602
+ <Dialog open={open} onOpenChange={onOpenChange}>
603
+ <DialogContent
604
+ showCloseButton={false}
605
+ aria-describedby={undefined}
606
+ style={
607
+ {
608
+ "--chat-widget-fullscreen-height":
609
+ lockedFullscreenHeight != null
610
+ ? `${lockedFullscreenHeight}px`
611
+ : "100svh",
612
+ } as React.CSSProperties
613
+ }
614
+ className={cn(
615
+ "z-50 flex fixed! inset-0! h-(--chat-widget-fullscreen-height)! max-h-(--chat-widget-fullscreen-height)! w-screen! max-w-none translate-x-0! translate-y-0! flex-col gap-0 rounded-none border-0 p-0 shadow-lg",
616
+ "data-[state=open]:zoom-in-100 data-[state=closed]:zoom-out-100",
617
+ )}
618
+ >
619
+ <DialogTitle className="sr-only">Chat</DialogTitle>
620
+ {children}
621
+ </DialogContent>
622
+ </Dialog>
623
+ );
624
+ }
625
+
626
+ return (
627
+ <motion.div
628
+ className={cn(desktopClassName, "z-10 flex h-112.5 flex-col")}
629
+ style={
630
+ {
631
+ ...desktopStyle,
632
+ pointerEvents: open ? "auto" : "none",
633
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
634
+ } as any
635
+ }
636
+ initial={false}
637
+ animate={{
638
+ opacity: open ? 1 : 0,
639
+ visibility: open ? "visible" : "hidden",
640
+ scale: open ? 1 : 0.96,
641
+ }}
642
+ transition={panelTransition}
643
+ >
644
+ {children}
645
+ </motion.div>
646
+ );
647
+ }
648
+
649
+ export function useIsChatWidgetFullscreenViewport() {
650
+ const [isFullscreenViewport, setIsFullscreenViewport] = React.useState(
651
+ () =>
652
+ typeof window !== "undefined" &&
653
+ window.innerWidth < CHAT_WIDGET_FULLSCREEN_BREAKPOINT,
654
+ );
655
+
656
+ React.useEffect(() => {
657
+ const mql = window.matchMedia(
658
+ `(max-width: ${CHAT_WIDGET_FULLSCREEN_BREAKPOINT - 1}px)`,
659
+ );
660
+ const onChange = () => {
661
+ setIsFullscreenViewport(
662
+ window.innerWidth < CHAT_WIDGET_FULLSCREEN_BREAKPOINT,
663
+ );
664
+ };
665
+
666
+ mql.addEventListener("change", onChange);
667
+ setIsFullscreenViewport(
668
+ window.innerWidth < CHAT_WIDGET_FULLSCREEN_BREAKPOINT,
669
+ );
670
+
671
+ return () => mql.removeEventListener("change", onChange);
672
+ }, []);
673
+
674
+ return isFullscreenViewport;
675
+ }
676
+
677
+ function useLockedFullscreenViewportHeight(active: boolean, enabled: boolean) {
678
+ const [lockedHeight, setLockedHeight] = React.useState<number | null>(null);
679
+
680
+ React.useEffect(() => {
681
+ if (!active || !enabled) {
682
+ setLockedHeight(null);
683
+ return;
684
+ }
685
+
686
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
687
+ setLockedHeight(Math.round(viewportHeight));
688
+ }, [active, enabled]);
689
+
690
+ return lockedHeight;
691
+ }
692
+
693
+ /** Bubble launcher button. */
694
+ export function ChatWidgetLauncher({
695
+ open,
696
+ onToggle,
697
+ appearance,
698
+ className,
699
+ }: {
700
+ open: boolean;
701
+ onToggle: () => void;
702
+ appearance?: ChatWidgetAppearance;
703
+ className?: string;
704
+ }) {
705
+ return (
706
+ <Button
707
+ onClick={onToggle}
708
+ size="icon"
709
+ className={cn("h-12 w-12 rounded-full shadow-lg text-white", className)}
710
+ style={getChatWidgetGradientStyle(appearance)}
711
+ aria-label={open ? "Close chat" : "Open chat"}
712
+ >
713
+ {open ? (
714
+ <Square className="size-4" />
715
+ ) : (
716
+ <ArrowUpRight className="size-4" />
717
+ )}
718
+ </Button>
719
+ );
720
+ }