@wealthx/shadcn 1.5.24 → 1.5.25
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/.turbo/turbo-build.log +144 -144
- package/CHANGELOG.md +6 -0
- package/dist/{chunk-RYGZRDP6.mjs → chunk-4FJC64FV.mjs} +468 -410
- package/dist/{chunk-FGHM34AV.mjs → chunk-7JVKSZ4O.mjs} +1 -1
- package/dist/{chunk-6HTE24TP.mjs → chunk-EY5YPFKX.mjs} +1 -1
- package/dist/{chunk-3HFOSFOM.mjs → chunk-IG7DEIWU.mjs} +4 -4
- package/dist/{chunk-2VTOF7PW.mjs → chunk-K2KX3NX7.mjs} +1 -1
- package/dist/{chunk-NXZ2F4JA.mjs → chunk-KGBLORCQ.mjs} +1 -1
- package/dist/{chunk-2SDEURIQ.mjs → chunk-PX2B3Q3A.mjs} +7 -7
- package/dist/{chunk-66NM4AX2.mjs → chunk-QM7LU2BR.mjs} +10 -10
- package/dist/{chunk-4UCRTTVL.mjs → chunk-RNLIZRAK.mjs} +1 -1
- package/dist/{chunk-BWG7AX6X.mjs → chunk-SQ54W3JH.mjs} +1 -1
- package/dist/{chunk-RJHE3V4M.mjs → chunk-UMR3HVZF.mjs} +4 -4
- package/dist/{chunk-JGUC3KCA.mjs → chunk-VNB5E7SI.mjs} +5 -5
- package/dist/{chunk-ETT5JAXF.mjs → chunk-Z5QI7CW2.mjs} +1 -1
- package/dist/components/ui/about-you-form.mjs +4 -4
- package/dist/components/ui/ai-assistant-drawer.mjs +2 -2
- package/dist/components/ui/{ai-conversations.js → ai-conversations/index.js} +818 -763
- package/dist/components/ui/ai-conversations/index.mjs +42 -0
- package/dist/components/ui/appointment-action-dialogs.mjs +1 -1
- package/dist/components/ui/appointment-availability-settings.mjs +5 -5
- package/dist/components/ui/appointment-book-dialog.mjs +4 -4
- package/dist/components/ui/appointment-detail-sheet.mjs +3 -3
- package/dist/components/ui/appointment-upcoming-card.mjs +1 -1
- package/dist/components/ui/assets-liabilities-side-card.mjs +6 -6
- package/dist/components/ui/backoffice-signup-steps.mjs +4 -4
- package/dist/components/ui/bank-statement-generate-dialog.mjs +6 -6
- package/dist/components/ui/contact-alert-dialog/index.mjs +2 -2
- package/dist/components/ui/create-contact-modal.mjs +3 -3
- package/dist/components/ui/date-picker.mjs +2 -2
- package/dist/components/ui/expense-detail-item.mjs +3 -3
- package/dist/components/ui/expense-work-details.mjs +9 -9
- package/dist/components/ui/file-preview-dialog.mjs +2 -2
- package/dist/components/ui/financial-drawers.mjs +2 -2
- package/dist/components/ui/form-primitives.mjs +2 -2
- package/dist/components/ui/frontend-signup-steps.mjs +5 -5
- package/dist/components/ui/income-work-details.mjs +3 -3
- package/dist/components/ui/kanban-column.mjs +4 -4
- package/dist/components/ui/opportunity-card.mjs +1 -1
- package/dist/components/ui/opportunity-edit-modals.mjs +6 -6
- package/dist/components/ui/opportunity-summary-tab.mjs +8 -8
- package/dist/components/ui/pipeline-board.mjs +6 -6
- package/dist/components/ui/pipeline-dialogs.mjs +5 -5
- package/dist/components/ui/property-report-dialog.mjs +4 -4
- package/dist/components/ui/savings-goal-modal.mjs +4 -4
- package/dist/components/ui/share-details-dialog.mjs +2 -2
- package/dist/components/ui/signup-form-primitives.mjs +1 -1
- package/dist/index.js +4913 -4858
- package/dist/index.mjs +160 -160
- package/dist/styles.css +1 -1
- package/package.json +4 -4
- package/src/components/index.tsx +1 -0
- package/src/components/ui/ai-conversations/helpers.tsx +31 -0
- package/src/components/ui/ai-conversations/index.tsx +468 -0
- package/src/components/ui/ai-conversations/lead-panel.tsx +517 -0
- package/src/components/ui/ai-conversations/list.tsx +335 -0
- package/src/components/ui/ai-conversations/thread.tsx +919 -0
- package/src/components/ui/ai-conversations/types.ts +83 -0
- package/src/styles/styles-css.ts +1 -1
- package/tsup.config.ts +1 -1
- package/dist/components/ui/ai-conversations.mjs +0 -42
- package/src/components/ui/ai-conversations.tsx +0 -2270
- package/dist/{chunk-A43XIVO6.mjs → chunk-2PWTXE66.mjs} +3 -3
- package/dist/{chunk-PSBQ4I3M.mjs → chunk-53ZB2JWT.mjs} +6 -6
- package/dist/{chunk-G6RCC2SF.mjs → chunk-5ST6BK7R.mjs} +3 -3
- package/dist/{chunk-H5NI6ZIU.mjs → chunk-734FOOJC.mjs} +3 -3
- package/dist/{chunk-ONYADWSO.mjs → chunk-ABVCQWDY.mjs} +3 -3
- package/dist/{chunk-AADJ5IT6.mjs → chunk-ECC2LLZM.mjs} +3 -3
- package/dist/{chunk-DK55HZPN.mjs → chunk-KENLXFJ7.mjs} +6 -6
- package/dist/{chunk-ET4MTPIY.mjs → chunk-LDC6V6DJ.mjs} +3 -3
- package/dist/{chunk-3S4XQTAL.mjs → chunk-SO4RB3XB.mjs} +9 -9
- package/dist/{chunk-UMF6LLQK.mjs → chunk-TRM3KIHT.mjs} +3 -3
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import TiptapUnderline from "@tiptap/extension-underline";
|
|
5
|
+
import TiptapLink from "@tiptap/extension-link";
|
|
6
|
+
import ReactMarkdown from "react-markdown";
|
|
7
|
+
import rehypeRaw from "rehype-raw";
|
|
8
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
9
|
+
import {
|
|
10
|
+
Archive,
|
|
11
|
+
ArrowLeft,
|
|
12
|
+
Bold,
|
|
13
|
+
Bot,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
Flag,
|
|
17
|
+
Italic,
|
|
18
|
+
Link2,
|
|
19
|
+
Lock,
|
|
20
|
+
Mail,
|
|
21
|
+
MessageSquare,
|
|
22
|
+
MoreHorizontal,
|
|
23
|
+
Paperclip,
|
|
24
|
+
Send,
|
|
25
|
+
Underline,
|
|
26
|
+
UserCheck,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import { cn, getInitials } from "@/lib/utils";
|
|
29
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
30
|
+
import { Button, buttonVariants } from "@/components/ui/button";
|
|
31
|
+
import {
|
|
32
|
+
DropdownMenu,
|
|
33
|
+
DropdownMenuContent,
|
|
34
|
+
DropdownMenuItem,
|
|
35
|
+
DropdownMenuSeparator,
|
|
36
|
+
DropdownMenuTrigger,
|
|
37
|
+
} from "@/components/ui/dropdown-menu";
|
|
38
|
+
import {
|
|
39
|
+
Popover,
|
|
40
|
+
PopoverContent,
|
|
41
|
+
PopoverTrigger,
|
|
42
|
+
} from "@/components/ui/popover";
|
|
43
|
+
import { Separator } from "@/components/ui/separator";
|
|
44
|
+
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
45
|
+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
46
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
47
|
+
import type {
|
|
48
|
+
AiConvChannel,
|
|
49
|
+
AiConvContact,
|
|
50
|
+
AiConvMessage,
|
|
51
|
+
AiConvMessageRole,
|
|
52
|
+
AiConvMode,
|
|
53
|
+
AiConvStatus,
|
|
54
|
+
} from "./types";
|
|
55
|
+
import {
|
|
56
|
+
ContactAvatar,
|
|
57
|
+
displayContactName,
|
|
58
|
+
PANEL_HEADER_HEIGHT,
|
|
59
|
+
} from "./helpers.tsx";
|
|
60
|
+
import { ConversationStatusChip } from "./list";
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// ChatBubble
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface ChatBubbleProps {
|
|
67
|
+
message: AiConvMessage;
|
|
68
|
+
/** Channel the conversation is using — affects rendering style for email. */
|
|
69
|
+
channel?: AiConvChannel;
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function BubbleAvatar({
|
|
74
|
+
role,
|
|
75
|
+
senderName,
|
|
76
|
+
}: {
|
|
77
|
+
role: AiConvMessageRole;
|
|
78
|
+
senderName?: string;
|
|
79
|
+
}) {
|
|
80
|
+
if (role === "bot") {
|
|
81
|
+
return (
|
|
82
|
+
<Avatar size="sm">
|
|
83
|
+
<AvatarFallback className="border border-border bg-muted">
|
|
84
|
+
<Bot className="size-3.5 text-muted-foreground" />
|
|
85
|
+
</AvatarFallback>
|
|
86
|
+
</Avatar>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (role === "advisor") {
|
|
90
|
+
return (
|
|
91
|
+
<Avatar size="sm">
|
|
92
|
+
<AvatarFallback className="font-semibold">
|
|
93
|
+
{getInitials(senderName ?? "Advisor")}
|
|
94
|
+
</AvatarFallback>
|
|
95
|
+
</Avatar>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<Avatar size="sm">
|
|
100
|
+
<AvatarFallback>{getInitials(senderName ?? "?")}</AvatarFallback>
|
|
101
|
+
</Avatar>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ChatBubble({ message, channel, className }: ChatBubbleProps) {
|
|
106
|
+
const { role, content, timestamp, senderName, subject } = message;
|
|
107
|
+
const isEmail = channel === "email";
|
|
108
|
+
|
|
109
|
+
if (role === "system") {
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn("my-2 flex items-center gap-3 px-2", className)}>
|
|
112
|
+
<Separator className="flex-1" />
|
|
113
|
+
<span className="shrink-0 text-caption text-muted-foreground">
|
|
114
|
+
{content}
|
|
115
|
+
</span>
|
|
116
|
+
<Separator className="flex-1" />
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isAdvisor = role === "advisor";
|
|
122
|
+
const isBot = role === "bot";
|
|
123
|
+
const isVisitor = role === "visitor";
|
|
124
|
+
|
|
125
|
+
const displayName = isBot
|
|
126
|
+
? "AI Assistant"
|
|
127
|
+
: isAdvisor
|
|
128
|
+
? (senderName ?? "Advisor")
|
|
129
|
+
: (senderName ?? "Lead");
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex gap-2.5",
|
|
135
|
+
isAdvisor ? "flex-row-reverse" : "flex-row",
|
|
136
|
+
className,
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<BubbleAvatar role={role} senderName={senderName} />
|
|
140
|
+
|
|
141
|
+
{/* Bubble + label */}
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"flex flex-col gap-1",
|
|
145
|
+
isEmail ? "max-w-[85%] w-full" : "max-w-[70%]",
|
|
146
|
+
isAdvisor && "items-end",
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<span className="text-caption text-muted-foreground">
|
|
150
|
+
{displayName}
|
|
151
|
+
</span>
|
|
152
|
+
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
"text-sm leading-relaxed break-words",
|
|
156
|
+
!isEmail && "px-3 py-2 [&_p]:m-0 [&_a]:underline",
|
|
157
|
+
isEmail && [
|
|
158
|
+
"border border-border bg-background px-4 py-3 w-full",
|
|
159
|
+
"[&_p]:mb-2 [&_p:last-child]:mb-0",
|
|
160
|
+
"[&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-4",
|
|
161
|
+
"[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-4",
|
|
162
|
+
"[&_li]:mb-0.5",
|
|
163
|
+
"[&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-bold",
|
|
164
|
+
"[&_h2]:mb-1.5 [&_h2]:text-sm [&_h2]:font-semibold",
|
|
165
|
+
"[&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium",
|
|
166
|
+
"[&_strong]:font-semibold [&_em]:italic",
|
|
167
|
+
"[&_a]:underline [&_a]:text-primary",
|
|
168
|
+
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_blockquote]:italic",
|
|
169
|
+
"[&_hr]:my-3 [&_hr]:border-border",
|
|
170
|
+
],
|
|
171
|
+
!isEmail &&
|
|
172
|
+
isBot &&
|
|
173
|
+
"border border-border bg-muted/60 text-foreground [&_a]:text-primary",
|
|
174
|
+
!isEmail &&
|
|
175
|
+
isVisitor &&
|
|
176
|
+
"border border-border bg-background text-foreground [&_a]:text-primary",
|
|
177
|
+
!isEmail &&
|
|
178
|
+
isAdvisor &&
|
|
179
|
+
"bg-primary text-primary-foreground [&_a]:text-primary-foreground",
|
|
180
|
+
isEmail && isAdvisor && "bg-muted/30",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{isEmail && subject && (
|
|
184
|
+
<div className="mb-2.5 border-b border-border pb-2">
|
|
185
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
186
|
+
Subject:
|
|
187
|
+
</span>{" "}
|
|
188
|
+
<span className="text-sm font-semibold text-foreground">
|
|
189
|
+
{subject}
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<ReactMarkdown
|
|
194
|
+
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
|
|
195
|
+
>
|
|
196
|
+
{content}
|
|
197
|
+
</ReactMarkdown>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{timestamp && (
|
|
201
|
+
<span className="text-caption text-muted-foreground">
|
|
202
|
+
{timestamp}
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// ChatComposer
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
export interface AiConvEmailPayload {
|
|
215
|
+
content: string;
|
|
216
|
+
to: string;
|
|
217
|
+
cc: string;
|
|
218
|
+
subject: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ChatComposerProps {
|
|
222
|
+
mode: AiConvMode;
|
|
223
|
+
/** Active reply channel. Defaults to "chat". */
|
|
224
|
+
channel?: AiConvChannel;
|
|
225
|
+
onChannelChange?: (channel: AiConvChannel) => void;
|
|
226
|
+
/**
|
|
227
|
+
* When true, the Email tab is shown in the composer. Defaults to false —
|
|
228
|
+
* consumers must opt in once their tenant's email integration is wired up.
|
|
229
|
+
*/
|
|
230
|
+
isEmailIntegrated?: boolean;
|
|
231
|
+
/** Lead's email address — pre-fills the To field in email compose. */
|
|
232
|
+
contactEmail?: string;
|
|
233
|
+
inputValue?: string;
|
|
234
|
+
onInputChange?: (v: string) => void;
|
|
235
|
+
/** Fired when the user sends a chat message. */
|
|
236
|
+
onSend?: (content: string) => void;
|
|
237
|
+
/** Fired when the user sends an email. */
|
|
238
|
+
onSendEmail?: (payload: AiConvEmailPayload) => void;
|
|
239
|
+
onTakeOver?: () => void;
|
|
240
|
+
onLetAiHandle?: () => void;
|
|
241
|
+
/** Pre-fills the Subject field with "Re: [emailReplySubject]" for email threads. */
|
|
242
|
+
emailReplySubject?: string;
|
|
243
|
+
className?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ComposerToolbarButton({
|
|
247
|
+
label,
|
|
248
|
+
icon: Icon,
|
|
249
|
+
pressed,
|
|
250
|
+
onToggle,
|
|
251
|
+
}: {
|
|
252
|
+
label: string;
|
|
253
|
+
icon: React.ElementType;
|
|
254
|
+
pressed?: boolean;
|
|
255
|
+
onToggle?: () => void;
|
|
256
|
+
}) {
|
|
257
|
+
return (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
aria-label={label}
|
|
261
|
+
aria-pressed={pressed}
|
|
262
|
+
onClick={onToggle}
|
|
263
|
+
className={cn(
|
|
264
|
+
"flex size-7 items-center justify-center transition-colors",
|
|
265
|
+
pressed
|
|
266
|
+
? "bg-foreground text-background"
|
|
267
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
268
|
+
)}
|
|
269
|
+
>
|
|
270
|
+
<Icon className="size-3.5" />
|
|
271
|
+
</button>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function ComposerLinkPopover({
|
|
276
|
+
editor,
|
|
277
|
+
}: {
|
|
278
|
+
editor: ReturnType<typeof useEditor>;
|
|
279
|
+
}) {
|
|
280
|
+
const [open, setOpen] = React.useState(false);
|
|
281
|
+
const [url, setUrl] = React.useState("");
|
|
282
|
+
|
|
283
|
+
const handleApply = () => {
|
|
284
|
+
if (url.trim()) {
|
|
285
|
+
editor?.chain().focus().setLink({ href: url.trim() }).run();
|
|
286
|
+
}
|
|
287
|
+
setOpen(false);
|
|
288
|
+
setUrl("");
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<Popover
|
|
293
|
+
open={open}
|
|
294
|
+
onOpenChange={(newOpen) => {
|
|
295
|
+
if (newOpen && editor?.isActive("link")) {
|
|
296
|
+
editor.chain().focus().unsetLink().run();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (newOpen) setUrl("");
|
|
300
|
+
setOpen(newOpen);
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<PopoverTrigger
|
|
304
|
+
aria-label="Insert link"
|
|
305
|
+
className={cn(
|
|
306
|
+
"flex size-7 items-center justify-center transition-colors",
|
|
307
|
+
editor?.isActive("link")
|
|
308
|
+
? "bg-foreground text-background"
|
|
309
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<Link2 className="size-3.5" />
|
|
313
|
+
</PopoverTrigger>
|
|
314
|
+
<PopoverContent className="w-72 p-2" align="start">
|
|
315
|
+
<div className="flex items-center gap-1.5">
|
|
316
|
+
<input
|
|
317
|
+
type="url"
|
|
318
|
+
value={url}
|
|
319
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
320
|
+
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
|
321
|
+
placeholder="https://"
|
|
322
|
+
className="min-w-0 flex-1 border border-border bg-transparent px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary"
|
|
323
|
+
autoFocus
|
|
324
|
+
/>
|
|
325
|
+
<Button size="sm" className="h-8 px-3" onClick={handleApply}>
|
|
326
|
+
Apply
|
|
327
|
+
</Button>
|
|
328
|
+
</div>
|
|
329
|
+
</PopoverContent>
|
|
330
|
+
</Popover>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ComposerEmailFieldRow({
|
|
335
|
+
label,
|
|
336
|
+
children,
|
|
337
|
+
}: {
|
|
338
|
+
label: string;
|
|
339
|
+
children: React.ReactNode;
|
|
340
|
+
}) {
|
|
341
|
+
return (
|
|
342
|
+
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5">
|
|
343
|
+
<span className="w-14 shrink-0 text-sm text-muted-foreground">
|
|
344
|
+
{label}
|
|
345
|
+
</span>
|
|
346
|
+
{children}
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function ChatComposer({
|
|
352
|
+
mode,
|
|
353
|
+
channel: channelProp = "chat",
|
|
354
|
+
onChannelChange,
|
|
355
|
+
isEmailIntegrated = false,
|
|
356
|
+
contactEmail = "",
|
|
357
|
+
inputValue = "",
|
|
358
|
+
onInputChange,
|
|
359
|
+
onSend,
|
|
360
|
+
onSendEmail,
|
|
361
|
+
onTakeOver,
|
|
362
|
+
onLetAiHandle,
|
|
363
|
+
emailReplySubject,
|
|
364
|
+
className,
|
|
365
|
+
}: ChatComposerProps) {
|
|
366
|
+
// Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change.
|
|
367
|
+
// Force chat when email isn't integrated so the panel never lands on a hidden tab.
|
|
368
|
+
const [channel, setChannel] = React.useState<AiConvChannel>(
|
|
369
|
+
isEmailIntegrated ? channelProp : "chat",
|
|
370
|
+
);
|
|
371
|
+
const [emailTo, setEmailTo] = React.useState(contactEmail);
|
|
372
|
+
const [emailCc, setEmailCc] = React.useState("");
|
|
373
|
+
const [showCc, setShowCc] = React.useState(false);
|
|
374
|
+
const [emailSubject, setEmailSubject] = React.useState(
|
|
375
|
+
emailReplySubject ? `Re: ${emailReplySubject}` : "",
|
|
376
|
+
);
|
|
377
|
+
const [emailMode, setEmailMode] = React.useState<"reply" | "new">(
|
|
378
|
+
emailReplySubject ? "reply" : "new",
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
|
382
|
+
|
|
383
|
+
const editor = useEditor({
|
|
384
|
+
extensions: [
|
|
385
|
+
StarterKit,
|
|
386
|
+
TiptapUnderline,
|
|
387
|
+
TiptapLink.configure({ openOnClick: false }),
|
|
388
|
+
],
|
|
389
|
+
content: "",
|
|
390
|
+
onTransaction: () => forceUpdate(),
|
|
391
|
+
editorProps: {
|
|
392
|
+
attributes: {
|
|
393
|
+
class:
|
|
394
|
+
"min-h-[150px] px-4 py-3 text-base text-foreground outline-none prose prose-sm max-w-none [&_p]:m-0 [&_a]:text-primary [&_a]:underline [&_a]:cursor-pointer",
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const handleChannelChange = (c: AiConvChannel) => {
|
|
400
|
+
setChannel(c);
|
|
401
|
+
onChannelChange?.(c);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const handleNewEmail = () => {
|
|
405
|
+
setEmailSubject("");
|
|
406
|
+
setEmailCc("");
|
|
407
|
+
setShowCc(false);
|
|
408
|
+
editor?.commands.clearContent();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const handleEmailModeChange = (mode: "reply" | "new") => {
|
|
412
|
+
setEmailMode(mode);
|
|
413
|
+
if (mode === "new") {
|
|
414
|
+
handleNewEmail();
|
|
415
|
+
} else {
|
|
416
|
+
setEmailSubject(emailReplySubject ? `Re: ${emailReplySubject}` : "");
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div
|
|
422
|
+
className={cn(
|
|
423
|
+
"flex flex-col border-t border-border bg-background",
|
|
424
|
+
className,
|
|
425
|
+
)}
|
|
426
|
+
>
|
|
427
|
+
{isEmailIntegrated && (
|
|
428
|
+
<div className="border-b border-border px-3 py-2">
|
|
429
|
+
<Tabs
|
|
430
|
+
value={channel}
|
|
431
|
+
onValueChange={(v) => v && handleChannelChange(v as AiConvChannel)}
|
|
432
|
+
>
|
|
433
|
+
<TabsList variant="default" className="w-full">
|
|
434
|
+
<TabsTrigger value="chat" className="flex-1 gap-1.5">
|
|
435
|
+
<MessageSquare className="size-3.5" />
|
|
436
|
+
Chat
|
|
437
|
+
</TabsTrigger>
|
|
438
|
+
<TabsTrigger value="email" className="flex-1 gap-1.5">
|
|
439
|
+
<Mail className="size-3.5" />
|
|
440
|
+
Email
|
|
441
|
+
</TabsTrigger>
|
|
442
|
+
</TabsList>
|
|
443
|
+
</Tabs>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{mode === "ai" ? (
|
|
448
|
+
<div className="flex items-center gap-2 bg-muted/30 px-4 py-2.5 text-[12px] text-muted-foreground">
|
|
449
|
+
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
|
|
450
|
+
<span>AI is handling this conversation.</span>
|
|
451
|
+
<Button
|
|
452
|
+
variant="link"
|
|
453
|
+
size="sm"
|
|
454
|
+
className="h-auto p-0 text-[12px] font-medium text-foreground"
|
|
455
|
+
onClick={onTakeOver}
|
|
456
|
+
>
|
|
457
|
+
Take Over
|
|
458
|
+
</Button>
|
|
459
|
+
<span>to reply directly.</span>
|
|
460
|
+
</div>
|
|
461
|
+
) : (
|
|
462
|
+
/* Email panel stays in normal flow to anchor container height;
|
|
463
|
+
chat panel is an absolute overlay so both tabs share identical dimensions */
|
|
464
|
+
<div className="relative">
|
|
465
|
+
<div
|
|
466
|
+
className={cn(
|
|
467
|
+
"flex flex-col",
|
|
468
|
+
channel !== "email" && "invisible pointer-events-none",
|
|
469
|
+
)}
|
|
470
|
+
aria-hidden={channel !== "email"}
|
|
471
|
+
>
|
|
472
|
+
<ComposerEmailFieldRow label="To">
|
|
473
|
+
<input
|
|
474
|
+
type="email"
|
|
475
|
+
value={emailTo}
|
|
476
|
+
onChange={(e) => setEmailTo(e.target.value)}
|
|
477
|
+
placeholder="Recipient email"
|
|
478
|
+
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
479
|
+
/>
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
onClick={() => setShowCc((v) => !v)}
|
|
483
|
+
className="flex shrink-0 items-center gap-0.5 text-sm text-muted-foreground hover:text-foreground"
|
|
484
|
+
>
|
|
485
|
+
CC
|
|
486
|
+
<ChevronDown className="size-3.5" />
|
|
487
|
+
</button>
|
|
488
|
+
</ComposerEmailFieldRow>
|
|
489
|
+
{showCc && (
|
|
490
|
+
<ComposerEmailFieldRow label="CC">
|
|
491
|
+
<input
|
|
492
|
+
type="email"
|
|
493
|
+
value={emailCc}
|
|
494
|
+
onChange={(e) => setEmailCc(e.target.value)}
|
|
495
|
+
placeholder="CC email"
|
|
496
|
+
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
497
|
+
/>
|
|
498
|
+
</ComposerEmailFieldRow>
|
|
499
|
+
)}
|
|
500
|
+
<ComposerEmailFieldRow label="Subject">
|
|
501
|
+
<input
|
|
502
|
+
type="text"
|
|
503
|
+
value={emailSubject}
|
|
504
|
+
onChange={(e) => setEmailSubject(e.target.value)}
|
|
505
|
+
placeholder="Email subject"
|
|
506
|
+
className="min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
|
507
|
+
/>
|
|
508
|
+
</ComposerEmailFieldRow>
|
|
509
|
+
<EditorContent editor={editor} />
|
|
510
|
+
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
|
511
|
+
<div className="flex items-center gap-0.5">
|
|
512
|
+
{emailReplySubject && (
|
|
513
|
+
<>
|
|
514
|
+
<ToggleGroup
|
|
515
|
+
type="single"
|
|
516
|
+
variant="outline"
|
|
517
|
+
size="sm"
|
|
518
|
+
spacing={0}
|
|
519
|
+
value={[emailMode]}
|
|
520
|
+
onValueChange={(values) => {
|
|
521
|
+
const v = values[0] as "reply" | "new" | undefined;
|
|
522
|
+
if (v) handleEmailModeChange(v);
|
|
523
|
+
}}
|
|
524
|
+
className="mr-1.5"
|
|
525
|
+
>
|
|
526
|
+
<ToggleGroupItem value="reply">Reply</ToggleGroupItem>
|
|
527
|
+
<ToggleGroupItem value="new">New email</ToggleGroupItem>
|
|
528
|
+
</ToggleGroup>
|
|
529
|
+
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
|
530
|
+
</>
|
|
531
|
+
)}
|
|
532
|
+
<ComposerToolbarButton
|
|
533
|
+
label="Bold"
|
|
534
|
+
icon={Bold}
|
|
535
|
+
pressed={editor?.isActive("bold")}
|
|
536
|
+
onToggle={() => editor?.chain().focus().toggleBold().run()}
|
|
537
|
+
/>
|
|
538
|
+
<ComposerToolbarButton
|
|
539
|
+
label="Italic"
|
|
540
|
+
icon={Italic}
|
|
541
|
+
pressed={editor?.isActive("italic")}
|
|
542
|
+
onToggle={() => editor?.chain().focus().toggleItalic().run()}
|
|
543
|
+
/>
|
|
544
|
+
<ComposerToolbarButton
|
|
545
|
+
label="Underline"
|
|
546
|
+
icon={Underline}
|
|
547
|
+
pressed={editor?.isActive("underline")}
|
|
548
|
+
onToggle={() =>
|
|
549
|
+
editor?.chain().focus().toggleUnderline().run()
|
|
550
|
+
}
|
|
551
|
+
/>
|
|
552
|
+
<Separator orientation="vertical" className="mx-1.5 h-4" />
|
|
553
|
+
<ComposerLinkPopover editor={editor} />
|
|
554
|
+
<ComposerToolbarButton label="Attach file" icon={Paperclip} />
|
|
555
|
+
</div>
|
|
556
|
+
<div className="flex items-center gap-2">
|
|
557
|
+
{onLetAiHandle && (
|
|
558
|
+
<Button variant="outline" size="sm" onClick={onLetAiHandle}>
|
|
559
|
+
<Bot className="mr-1.5 size-3.5" />
|
|
560
|
+
Let AI Handle
|
|
561
|
+
</Button>
|
|
562
|
+
)}
|
|
563
|
+
<Button
|
|
564
|
+
size="sm"
|
|
565
|
+
onClick={() => {
|
|
566
|
+
const html = editor?.getHTML() ?? "";
|
|
567
|
+
onSendEmail?.({
|
|
568
|
+
content: html,
|
|
569
|
+
to: emailTo,
|
|
570
|
+
cc: emailCc,
|
|
571
|
+
subject: emailSubject,
|
|
572
|
+
});
|
|
573
|
+
editor?.commands.clearContent();
|
|
574
|
+
}}
|
|
575
|
+
disabled={!editor || editor.isEmpty || !emailTo.trim()}
|
|
576
|
+
>
|
|
577
|
+
<Send className="mr-1.5 size-3.5" />
|
|
578
|
+
Send Email
|
|
579
|
+
</Button>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
{/* Chat compose — absolute overlay, fills exact same height as email panel */}
|
|
585
|
+
{channel === "chat" && (
|
|
586
|
+
<div className="absolute inset-0 flex flex-col gap-3 p-4">
|
|
587
|
+
<Textarea
|
|
588
|
+
value={inputValue}
|
|
589
|
+
onChange={(e) => onInputChange?.(e.target.value)}
|
|
590
|
+
placeholder="Reply to lead..."
|
|
591
|
+
className="min-h-0 flex-1 resize-none text-base"
|
|
592
|
+
/>
|
|
593
|
+
<div className="flex items-center justify-between">
|
|
594
|
+
<Button variant="outline" size="sm" onClick={onLetAiHandle}>
|
|
595
|
+
<Bot className="mr-1.5 size-3.5" />
|
|
596
|
+
Let AI Handle
|
|
597
|
+
</Button>
|
|
598
|
+
<Button
|
|
599
|
+
size="sm"
|
|
600
|
+
onClick={() => onSend?.(inputValue)}
|
|
601
|
+
disabled={!inputValue.trim()}
|
|
602
|
+
>
|
|
603
|
+
<Send className="mr-1.5 size-3.5" />
|
|
604
|
+
Send
|
|
605
|
+
</Button>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
)}
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// ChatThread
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
export interface ChatThreadProps {
|
|
620
|
+
contact: AiConvContact;
|
|
621
|
+
status: AiConvStatus;
|
|
622
|
+
mode: AiConvMode;
|
|
623
|
+
messages: AiConvMessage[];
|
|
624
|
+
isAiTyping?: boolean;
|
|
625
|
+
/** Active reply channel — "chat" (default) or "email". */
|
|
626
|
+
channel?: AiConvChannel;
|
|
627
|
+
onChannelChange?: (channel: AiConvChannel) => void;
|
|
628
|
+
/** When true, the Email tab is shown in the composer. Defaults to false. */
|
|
629
|
+
isEmailIntegrated?: boolean;
|
|
630
|
+
inputValue?: string;
|
|
631
|
+
onInputChange?: (v: string) => void;
|
|
632
|
+
/** Fired when the user sends a chat message. */
|
|
633
|
+
onSend?: (content: string) => void;
|
|
634
|
+
/** Fired when the user sends an email. */
|
|
635
|
+
onSendEmail?: (payload: AiConvEmailPayload) => void;
|
|
636
|
+
onTakeOver?: () => void;
|
|
637
|
+
onLetAiHandle?: () => void;
|
|
638
|
+
/** Pre-fills the email Subject field with "Re: [emailReplySubject]" for reply threads. */
|
|
639
|
+
emailReplySubject?: string;
|
|
640
|
+
onReopen?: () => void;
|
|
641
|
+
onMarkUrgent?: () => void;
|
|
642
|
+
onUnmarkUrgent?: () => void;
|
|
643
|
+
onArchive?: () => void;
|
|
644
|
+
onAssignToAdvisor?: () => void;
|
|
645
|
+
/** True when older messages can be loaded (e.g. paginated history). */
|
|
646
|
+
hasMoreMessages?: boolean;
|
|
647
|
+
/** True while a `onLoadMoreMessages` request is in-flight. */
|
|
648
|
+
isLoadingMoreMessages?: boolean;
|
|
649
|
+
/** Fired when the consumer should fetch older messages. */
|
|
650
|
+
onLoadMoreMessages?: () => void;
|
|
651
|
+
/** Mobile only — back to conversation list. */
|
|
652
|
+
onBack?: () => void;
|
|
653
|
+
/** Mobile only — show lead info panel. */
|
|
654
|
+
onShowLeadInfo?: () => void;
|
|
655
|
+
className?: string;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function ChatThread({
|
|
659
|
+
contact,
|
|
660
|
+
status,
|
|
661
|
+
mode,
|
|
662
|
+
messages,
|
|
663
|
+
isAiTyping = false,
|
|
664
|
+
channel,
|
|
665
|
+
onChannelChange,
|
|
666
|
+
isEmailIntegrated,
|
|
667
|
+
inputValue,
|
|
668
|
+
onInputChange,
|
|
669
|
+
onSend,
|
|
670
|
+
onSendEmail,
|
|
671
|
+
onTakeOver,
|
|
672
|
+
onLetAiHandle,
|
|
673
|
+
emailReplySubject,
|
|
674
|
+
onReopen,
|
|
675
|
+
onMarkUrgent,
|
|
676
|
+
onUnmarkUrgent,
|
|
677
|
+
onArchive,
|
|
678
|
+
onAssignToAdvisor,
|
|
679
|
+
hasMoreMessages,
|
|
680
|
+
isLoadingMoreMessages,
|
|
681
|
+
onLoadMoreMessages,
|
|
682
|
+
onBack,
|
|
683
|
+
onShowLeadInfo,
|
|
684
|
+
className,
|
|
685
|
+
}: ChatThreadProps) {
|
|
686
|
+
const aiIsHandling = mode === "ai";
|
|
687
|
+
const isClosed = status === "closed";
|
|
688
|
+
|
|
689
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
690
|
+
// Captures scrollHeight just before older messages are prepended, so we can
|
|
691
|
+
// restore the user's visible scroll offset once the new nodes render.
|
|
692
|
+
const preLoadScrollHeightRef = React.useRef<number | null>(null);
|
|
693
|
+
|
|
694
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
695
|
+
if (!hasMoreMessages || isLoadingMoreMessages || !onLoadMoreMessages) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (e.currentTarget.scrollTop <= 80) {
|
|
699
|
+
preLoadScrollHeightRef.current = e.currentTarget.scrollHeight;
|
|
700
|
+
onLoadMoreMessages();
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Tracks the last "tail" message id so we can tell an append (new message,
|
|
705
|
+
// tail changed) apart from a prepend (older history loaded, tail unchanged).
|
|
706
|
+
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
|
707
|
+
const prevContactIdRef = React.useRef<string>(contact.id);
|
|
708
|
+
|
|
709
|
+
React.useLayoutEffect(() => {
|
|
710
|
+
const el = scrollRef.current;
|
|
711
|
+
if (!el) return;
|
|
712
|
+
|
|
713
|
+
// Prepend (older messages just loaded) — restore scroll so the user
|
|
714
|
+
// stays anchored to the message they were reading.
|
|
715
|
+
if (preLoadScrollHeightRef.current !== null) {
|
|
716
|
+
el.scrollTop = el.scrollHeight - preLoadScrollHeightRef.current;
|
|
717
|
+
preLoadScrollHeightRef.current = null;
|
|
718
|
+
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
|
|
719
|
+
prevContactIdRef.current = contact.id;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const currentLastId = messages[messages.length - 1]?.id;
|
|
724
|
+
const contactChanged = prevContactIdRef.current !== contact.id;
|
|
725
|
+
const tailChanged = prevLastMessageIdRef.current !== currentLastId;
|
|
726
|
+
|
|
727
|
+
// Opening a conversation or appending a new message (sent, received,
|
|
728
|
+
// or system) — pin to the bottom.
|
|
729
|
+
if (contactChanged || tailChanged) {
|
|
730
|
+
el.scrollTop = el.scrollHeight;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
prevLastMessageIdRef.current = currentLastId;
|
|
734
|
+
prevContactIdRef.current = contact.id;
|
|
735
|
+
}, [contact.id, messages]);
|
|
736
|
+
|
|
737
|
+
// Typing indicator adds DOM height — keep the view pinned to bottom.
|
|
738
|
+
React.useLayoutEffect(() => {
|
|
739
|
+
if (!isAiTyping) return;
|
|
740
|
+
const el = scrollRef.current;
|
|
741
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
742
|
+
}, [isAiTyping]);
|
|
743
|
+
|
|
744
|
+
return (
|
|
745
|
+
<div className={cn("flex flex-col bg-background", className)}>
|
|
746
|
+
{/* Header */}
|
|
747
|
+
<div
|
|
748
|
+
className={cn(
|
|
749
|
+
PANEL_HEADER_HEIGHT,
|
|
750
|
+
"flex items-center gap-3 border-b border-border px-4",
|
|
751
|
+
)}
|
|
752
|
+
>
|
|
753
|
+
{onBack && (
|
|
754
|
+
<Button
|
|
755
|
+
variant="ghost"
|
|
756
|
+
size="icon"
|
|
757
|
+
className="size-8 shrink-0 md:hidden"
|
|
758
|
+
onClick={onBack}
|
|
759
|
+
aria-label="Back to conversations"
|
|
760
|
+
>
|
|
761
|
+
<ArrowLeft className="size-4" />
|
|
762
|
+
</Button>
|
|
763
|
+
)}
|
|
764
|
+
|
|
765
|
+
<ContactAvatar name={contact.name} size="md" />
|
|
766
|
+
|
|
767
|
+
<div className="min-w-0 flex-1">
|
|
768
|
+
<div className="flex items-center gap-2">
|
|
769
|
+
<span className="text-sm font-semibold text-foreground">
|
|
770
|
+
{displayContactName(contact.name)}
|
|
771
|
+
</span>
|
|
772
|
+
<ConversationStatusChip status={status} showDot />
|
|
773
|
+
</div>
|
|
774
|
+
{contact.email && (
|
|
775
|
+
<p className="text-sm text-muted-foreground">{contact.email}</p>
|
|
776
|
+
)}
|
|
777
|
+
</div>
|
|
778
|
+
|
|
779
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
780
|
+
<div className="hidden items-center gap-2 md:flex">
|
|
781
|
+
{isClosed && (
|
|
782
|
+
<Button variant="outline" size="sm" onClick={onReopen}>
|
|
783
|
+
Reopen
|
|
784
|
+
</Button>
|
|
785
|
+
)}
|
|
786
|
+
{!isClosed && aiIsHandling && (
|
|
787
|
+
<Button size="sm" onClick={onTakeOver}>
|
|
788
|
+
Take Over
|
|
789
|
+
</Button>
|
|
790
|
+
)}
|
|
791
|
+
{!isClosed && !aiIsHandling && (
|
|
792
|
+
<Button variant="outline" size="sm" onClick={onLetAiHandle}>
|
|
793
|
+
<Bot className="mr-1.5 size-3.5" />
|
|
794
|
+
Let AI Handle
|
|
795
|
+
</Button>
|
|
796
|
+
)}
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
<DropdownMenu>
|
|
800
|
+
<DropdownMenuTrigger
|
|
801
|
+
className={cn(
|
|
802
|
+
buttonVariants({ variant: "ghost", size: "icon" }),
|
|
803
|
+
"size-8",
|
|
804
|
+
)}
|
|
805
|
+
aria-label="More actions"
|
|
806
|
+
>
|
|
807
|
+
<MoreHorizontal className="size-4" />
|
|
808
|
+
</DropdownMenuTrigger>
|
|
809
|
+
<DropdownMenuContent>
|
|
810
|
+
{onShowLeadInfo && (
|
|
811
|
+
<>
|
|
812
|
+
<DropdownMenuItem
|
|
813
|
+
className="md:hidden"
|
|
814
|
+
onClick={onShowLeadInfo}
|
|
815
|
+
>
|
|
816
|
+
<ChevronRight className="mr-2 size-4" />
|
|
817
|
+
Lead Info
|
|
818
|
+
</DropdownMenuItem>
|
|
819
|
+
<DropdownMenuSeparator className="md:hidden" />
|
|
820
|
+
</>
|
|
821
|
+
)}
|
|
822
|
+
{status === "needs-attention" ? (
|
|
823
|
+
<DropdownMenuItem onClick={onUnmarkUrgent}>
|
|
824
|
+
<Flag className="mr-2 size-4" />
|
|
825
|
+
Unmark Urgent
|
|
826
|
+
</DropdownMenuItem>
|
|
827
|
+
) : (
|
|
828
|
+
<DropdownMenuItem onClick={onMarkUrgent}>
|
|
829
|
+
<Flag className="mr-2 size-4" />
|
|
830
|
+
Mark as Urgent
|
|
831
|
+
</DropdownMenuItem>
|
|
832
|
+
)}
|
|
833
|
+
<DropdownMenuItem onClick={onAssignToAdvisor}>
|
|
834
|
+
<UserCheck className="mr-2 size-4" />
|
|
835
|
+
Assign to advisor
|
|
836
|
+
</DropdownMenuItem>
|
|
837
|
+
<DropdownMenuSeparator />
|
|
838
|
+
<DropdownMenuItem onClick={onArchive}>
|
|
839
|
+
<Archive className="mr-2 size-4" />
|
|
840
|
+
Archive
|
|
841
|
+
</DropdownMenuItem>
|
|
842
|
+
</DropdownMenuContent>
|
|
843
|
+
</DropdownMenu>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
|
|
847
|
+
{/* Messages */}
|
|
848
|
+
<div
|
|
849
|
+
ref={scrollRef}
|
|
850
|
+
onScroll={handleScroll}
|
|
851
|
+
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
|
852
|
+
tabIndex={0}
|
|
853
|
+
>
|
|
854
|
+
{isLoadingMoreMessages && (
|
|
855
|
+
<div className="flex justify-center py-1 text-caption text-muted-foreground">
|
|
856
|
+
Loading older messages...
|
|
857
|
+
</div>
|
|
858
|
+
)}
|
|
859
|
+
{messages.length === 0 ? (
|
|
860
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
861
|
+
<MessageSquare className="size-8 opacity-30" />
|
|
862
|
+
<p className="text-sm">No messages yet</p>
|
|
863
|
+
</div>
|
|
864
|
+
) : (
|
|
865
|
+
messages.map((msg) => (
|
|
866
|
+
<ChatBubble key={msg.id} message={msg} channel={channel} />
|
|
867
|
+
))
|
|
868
|
+
)}
|
|
869
|
+
|
|
870
|
+
{isAiTyping && !isClosed && (
|
|
871
|
+
<div className="flex gap-2.5">
|
|
872
|
+
<BubbleAvatar role="bot" />
|
|
873
|
+
<div className="flex flex-col gap-1">
|
|
874
|
+
<span className="text-caption text-muted-foreground">
|
|
875
|
+
AI Assistant
|
|
876
|
+
</span>
|
|
877
|
+
<div className="flex items-center gap-1 border border-border bg-muted/60 px-3 py-2.5">
|
|
878
|
+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
|
879
|
+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]" />
|
|
880
|
+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]" />
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
)}
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
{/* Composer / locked banner */}
|
|
888
|
+
{isClosed ? (
|
|
889
|
+
<div className="flex items-center gap-3 border-t border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
|
890
|
+
<Lock className="size-3.5 shrink-0" />
|
|
891
|
+
<span>This conversation is closed.</span>
|
|
892
|
+
<Button
|
|
893
|
+
variant="outline"
|
|
894
|
+
size="sm"
|
|
895
|
+
className="ml-auto"
|
|
896
|
+
onClick={onReopen}
|
|
897
|
+
>
|
|
898
|
+
Reopen
|
|
899
|
+
</Button>
|
|
900
|
+
</div>
|
|
901
|
+
) : (
|
|
902
|
+
<ChatComposer
|
|
903
|
+
mode={mode}
|
|
904
|
+
channel={channel}
|
|
905
|
+
onChannelChange={onChannelChange}
|
|
906
|
+
isEmailIntegrated={isEmailIntegrated}
|
|
907
|
+
contactEmail={contact.email}
|
|
908
|
+
inputValue={inputValue}
|
|
909
|
+
onInputChange={onInputChange}
|
|
910
|
+
onSend={onSend}
|
|
911
|
+
onSendEmail={onSendEmail}
|
|
912
|
+
onTakeOver={onTakeOver}
|
|
913
|
+
onLetAiHandle={onLetAiHandle}
|
|
914
|
+
emailReplySubject={emailReplySubject}
|
|
915
|
+
/>
|
|
916
|
+
)}
|
|
917
|
+
</div>
|
|
918
|
+
);
|
|
919
|
+
}
|