@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.
- package/dist/widget.js +110 -3530
- package/package.json +1 -1
- package/src/chat-widget/chat-widget-defaults.tsx +1 -40
- package/src/chat-widget/message-reveal.ts +52 -0
- package/src/custom-element/agent-widget-element.tsx +18 -2
- package/src/styles/widget.css.ts +39 -10
- package/dist/widget.css +0 -2
- package/src/chat-widget/components/chat-widget-frame.tsx +0 -56
- package/src/chat-widget/components/chat-widget-layout.ts +0 -6
- package/src/chat-widget/components/chat-widget-primitives.tsx +0 -739
- package/src/chat-widget/components/confirmation.tsx +0 -155
- package/src/chat-widget/components/conversation.tsx +0 -110
- package/src/chat-widget/components/message.tsx +0 -325
- package/src/chat-widget/index.ts +0 -19
- package/src/chat-widget/styles/chat-widget-box.tsx +0 -258
- package/src/chat-widget/styles/chat-widget-bubble.tsx +0 -238
- package/src/chat-widget/styles/chat-widget-chatbar.tsx +0 -248
- package/src/chat-widget/styles/chat-widget-messenger.tsx +0 -573
|
@@ -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
|
-
}
|