@usereq/widget 0.2.9 → 0.2.10

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