@handled-ai/design-system 0.18.58 → 0.19.0-rc.1

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 (64) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/case-panel-activity-timeline.d.ts +2 -0
  4. package/dist/components/case-panel-activity-timeline.js +22 -1
  5. package/dist/components/case-panel-activity-timeline.js.map +1 -1
  6. package/dist/components/comment-composer.d.ts +29 -0
  7. package/dist/components/comment-composer.js +102 -0
  8. package/dist/components/comment-composer.js.map +1 -0
  9. package/dist/components/conversation-panel.d.ts +95 -0
  10. package/dist/components/conversation-panel.js +636 -0
  11. package/dist/components/conversation-panel.js.map +1 -0
  12. package/dist/components/detail-view.js +1 -1
  13. package/dist/components/detail-view.js.map +1 -1
  14. package/dist/components/owner-chips.d.ts +59 -0
  15. package/dist/components/owner-chips.js +256 -0
  16. package/dist/components/owner-chips.js.map +1 -0
  17. package/dist/components/pill.d.ts +1 -1
  18. package/dist/components/score-why-chips.d.ts +1 -1
  19. package/dist/components/signal-priority-popover.d.ts +1 -1
  20. package/dist/components/signal-priority-popover.js +16 -7
  21. package/dist/components/signal-priority-popover.js.map +1 -1
  22. package/dist/components/tabs.d.ts +1 -1
  23. package/dist/components/timeline-activity.d.ts +7 -0
  24. package/dist/components/timeline-activity.js +22 -1
  25. package/dist/components/timeline-activity.js.map +1 -1
  26. package/dist/components/virtualized-data-table.js +4 -4
  27. package/dist/components/virtualized-data-table.js.map +1 -1
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/safe-html.d.ts +11 -0
  32. package/dist/internal/safe-html.js +222 -0
  33. package/dist/internal/safe-html.js.map +1 -0
  34. package/dist/prototype/index.d.ts +1 -1
  35. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  36. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  37. package/dist/prototype/prototype-config.d.ts +1 -1
  38. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  39. package/dist/prototype/prototype-inbox-view.js +2 -0
  40. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  41. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  42. package/dist/prototype/prototype-shell.d.ts +1 -1
  43. package/dist/{signal-priority-popover-QJngMAj7.d.ts → signal-priority-popover-CZitE9xq.d.ts} +11 -2
  44. package/package.json +1 -1
  45. package/src/components/__tests__/comment-composer.test.tsx +57 -0
  46. package/src/components/__tests__/conversation-panel.test.tsx +157 -0
  47. package/src/components/__tests__/owner-chips.test.tsx +100 -0
  48. package/src/components/__tests__/signal-priority-popover.test.tsx +41 -4
  49. package/src/components/__tests__/timeline-activity.test.tsx +55 -0
  50. package/src/components/__tests__/virtualized-data-table-resize.test.tsx +18 -0
  51. package/src/components/case-panel-activity-timeline.tsx +20 -0
  52. package/src/components/comment-composer.tsx +119 -0
  53. package/src/components/conversation-panel.tsx +790 -0
  54. package/src/components/detail-view.tsx +3 -1
  55. package/src/components/owner-chips.tsx +335 -0
  56. package/src/components/signal-priority-popover.tsx +19 -6
  57. package/src/components/timeline-activity.tsx +37 -3
  58. package/src/components/virtualized-data-table.tsx +4 -4
  59. package/src/index.ts +4 -1
  60. package/src/internal/__tests__/safe-html.test.ts +53 -0
  61. package/src/internal/safe-html.ts +284 -0
  62. package/src/prototype/__tests__/detail-view-score-why.test.tsx +34 -0
  63. package/src/prototype/prototype-config.ts +5 -1
  64. package/src/prototype/prototype-inbox-view.tsx +2 -0
@@ -0,0 +1,790 @@
1
+ "use client"
2
+
3
+ /**
4
+ * conversation-panel.tsx — in-case email-thread reader + reply, for the case
5
+ * panel ("Email response detected" hub).
6
+ *
7
+ * v1 scope (WIT-853 / WIT-802): INLINE thread reading + reply only.
8
+ * - A collapsible hub header with a pulse badge whose color reflects state:
9
+ * responded (a reply needs you) / awaiting (sent, waiting) / viewing (read-only).
10
+ * - A list of thread rows; clicking one opens a Gmail-style reader inline,
11
+ * right under the row, with collapsible messages + quoted-history toggle.
12
+ * - Reply / Reply-all composer with a signature toggle and two send paths:
13
+ * Preview→Send (in-app) and "Open draft in Gmail" (deep link).
14
+ * - A "playbook stopped" banner when the customer's reply halted the sequence,
15
+ * and a read-only notice when the operator is not a thread participant.
16
+ *
17
+ * The bottom-right floating dock / side-by-side compare is intentionally OUT of
18
+ * scope here and tracked separately (WIT-855).
19
+ *
20
+ * Presentational: all data + side effects come from the consumer. Email bodies
21
+ * (`bodyHtml`, `quoted.html`) are sanitized before rendering.
22
+ */
23
+
24
+ import * as React from "react"
25
+ import {
26
+ ChevronDown,
27
+ ChevronUp,
28
+ CornerUpLeft,
29
+ CheckCheck,
30
+ MailOpen,
31
+ Reply,
32
+ ReplyAll,
33
+ Eye,
34
+ Send,
35
+ Lock,
36
+ Pause,
37
+ GitMerge,
38
+ Check,
39
+ X,
40
+ } from "lucide-react"
41
+
42
+ import { cn } from "../lib/utils"
43
+ import { getInitials } from "../lib/user-display"
44
+ import { BRAND_ICONS } from "../lib/icons"
45
+ import { htmlToTextSnippet, sanitizeHtml } from "../internal/safe-html"
46
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
47
+ import { Button } from "./button"
48
+ import { Switch } from "./switch"
49
+ import { Textarea } from "./textarea"
50
+ import { RichTextToolbar } from "./rich-text-toolbar"
51
+ import {
52
+ Dialog,
53
+ DialogContent,
54
+ DialogHeader,
55
+ DialogTitle,
56
+ DialogDescription,
57
+ DialogFooter,
58
+ } from "./dialog"
59
+
60
+ /* ── Types ───────────────────────────────────────────────────────────────── */
61
+
62
+ export interface ConvParticipant {
63
+ name: string
64
+ email: string
65
+ avatarUrl?: string | null
66
+ role?: string
67
+ }
68
+
69
+ export interface ConvMessage {
70
+ id: string
71
+ direction: "inbound" | "outbound"
72
+ from: ConvParticipant
73
+ to: ConvParticipant
74
+ /** Absolute timestamp label, e.g. "Jun 1, 2026, 9:12 AM". */
75
+ date: string
76
+ /** Relative label, e.g. "2 days ago". */
77
+ ago?: string
78
+ receipt?: { kind: "new" | "read" | "opened" | "sent"; label: string }
79
+ /** HTML body (preferred). Sanitized by the component before rendering. */
80
+ bodyHtml?: string
81
+ /** Plain-text fallback when `bodyHtml` is absent. */
82
+ body?: string
83
+ /** Quoted prior message, collapsed behind a toggle. Sanitized before rendering. */
84
+ quoted?: { attr: string; html: string }
85
+ }
86
+
87
+ export type ConvStatus = "responded" | "awaiting" | "viewing"
88
+
89
+ export interface ConversationThread {
90
+ threadId: string
91
+ subject: string
92
+ status: ConvStatus
93
+ /** Relative label for the most recent activity. */
94
+ lastWhen?: string
95
+ contact: ConvParticipant
96
+ cc?: ConvParticipant[]
97
+ /** Set when this thread's reply halted a playbook (terminal). */
98
+ paused?: { playbook: string } | null
99
+ /** false => operator is not a participant; reply disabled (read-only). */
100
+ canReply?: boolean
101
+ messages: ConvMessage[]
102
+ /** Prefilled reply draft body. */
103
+ draft?: string
104
+ /** Signature text appended to replies (plain text). */
105
+ signature?: string
106
+ }
107
+
108
+ export interface ConversationReplyPayload {
109
+ threadId: string
110
+ body: string
111
+ includeSignature: boolean
112
+ replyAll: boolean
113
+ }
114
+
115
+ export interface ConversationPanelProps {
116
+ threads: ConversationThread[]
117
+ /** Current operator: drives "to me" + the reply avatar. */
118
+ me?: ConvParticipant
119
+ /** Deployment brand, used in the paused-playbook copy. */
120
+ tenantName?: string
121
+ onSendReply?: (payload: ConversationReplyPayload) => void | Promise<void>
122
+ onCreateGmailDraft?: (payload: ConversationReplyPayload) => void
123
+ onOpenInGmail?: (threadId: string) => void
124
+ /** Inline-open this thread initially (defaults to the first responded one). */
125
+ defaultOpenThreadId?: string
126
+ className?: string
127
+ }
128
+
129
+ /* ── Shared helpers ──────────────────────────────────────────────────────── */
130
+
131
+ /** Gmail-like reading-pane typography (mirrors timeline-activity's email mode). */
132
+ const PROSE = cn(
133
+ "text-sm leading-[1.62] text-foreground/90 break-words",
134
+ "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
135
+ "[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
136
+ "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
137
+ "[&_img]:max-w-full [&_img]:h-auto"
138
+ )
139
+
140
+ function escapeHtml(s: string): string {
141
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
142
+ }
143
+
144
+ /** Plain-text -> simple paragraph HTML for the Preview / sent-message body. */
145
+ function textToHtml(text: string): string {
146
+ return text
147
+ .split(/\n{2,}/)
148
+ .map((p) => p.trim())
149
+ .filter(Boolean)
150
+ .map((p) => `<p>${escapeHtml(p).replace(/\n/g, "<br>")}</p>`)
151
+ .join("")
152
+ }
153
+
154
+ function GmailMark({ size = 14 }: { size?: number }) {
155
+ return (
156
+ // eslint-disable-next-line @next/next/no-img-element
157
+ <img
158
+ src={BRAND_ICONS.gmail.icon}
159
+ alt="Gmail"
160
+ width={size}
161
+ height={size}
162
+ style={{ width: size, height: size, objectFit: "contain", display: "block" }}
163
+ />
164
+ )
165
+ }
166
+
167
+ function PersonAvatar({ person, size = "sm" }: { person: ConvParticipant; size?: "sm" | "default" }) {
168
+ return (
169
+ <Avatar size={size}>
170
+ {person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
171
+ <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
172
+ {getInitials({ name: person.name, email: person.email })}
173
+ </AvatarFallback>
174
+ </Avatar>
175
+ )
176
+ }
177
+
178
+ function firstName(name: string): string {
179
+ return name.split(" ")[0] || name
180
+ }
181
+
182
+ const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
183
+ responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
184
+ awaiting: { label: "Awaiting", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
185
+ viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" },
186
+ }
187
+
188
+ const STATUS_DOT: Record<ConvStatus, string> = {
189
+ responded: "bg-status-active-fg",
190
+ awaiting: "bg-status-pending-fg",
191
+ viewing: "bg-muted-foreground/50",
192
+ }
193
+
194
+ function effectiveStatus(t: ConversationThread): ConvStatus {
195
+ return t.canReply === false ? "viewing" : t.status
196
+ }
197
+
198
+ /* ── One message (collapsible) ──────────────────────────────────────────── */
199
+
200
+ function MessageView({
201
+ message,
202
+ expanded,
203
+ onToggle,
204
+ me,
205
+ }: {
206
+ message: ConvMessage
207
+ expanded: boolean
208
+ onToggle: () => void
209
+ me?: ConvParticipant
210
+ }) {
211
+ const [quoteOpen, setQuoteOpen] = React.useState(false)
212
+ const snippet =
213
+ message.body?.split("\n").find(Boolean) ??
214
+ (message.bodyHtml ? htmlToTextSnippet(message.bodyHtml, 140) : "")
215
+
216
+ if (!expanded) {
217
+ return (
218
+ <button
219
+ type="button"
220
+ data-slot="conv-message-collapsed"
221
+ onClick={onToggle}
222
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40"
223
+ >
224
+ <PersonAvatar person={message.from} />
225
+ <span className="text-muted-foreground min-w-0 flex-1 truncate text-[13px]">
226
+ <b className="text-foreground">{firstName(message.from.name)}</b> · {snippet}
227
+ </span>
228
+ <span className="text-muted-foreground/60 shrink-0 text-xs">{message.ago ?? message.date}</span>
229
+ <ChevronDown size={13} className="text-muted-foreground shrink-0" />
230
+ </button>
231
+ )
232
+ }
233
+
234
+ const toLabel = me && message.to.email === me.email ? "me" : firstName(message.to.name)
235
+
236
+ return (
237
+ <div data-slot="conv-message" className="rounded-md border border-border bg-background">
238
+ <button
239
+ type="button"
240
+ onClick={onToggle}
241
+ className="flex w-full items-start gap-2 px-3 py-2 text-left"
242
+ >
243
+ <PersonAvatar person={message.from} size="default" />
244
+ <span className="min-w-0 flex-1">
245
+ <span className="flex flex-wrap items-baseline gap-x-1.5">
246
+ <span className="text-[13px] font-semibold">{message.from.name}</span>
247
+ <span className="text-muted-foreground/60 truncate text-xs">&lt;{message.from.email}&gt;</span>
248
+ </span>
249
+ <span className="text-muted-foreground block text-xs">
250
+ to <b>{toLabel}</b>
251
+ </span>
252
+ </span>
253
+ <span className="flex shrink-0 items-center gap-2">
254
+ {message.receipt ? (
255
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
256
+ {message.receipt.kind === "new" ? (
257
+ <CornerUpLeft size={11} />
258
+ ) : message.receipt.kind === "read" ? (
259
+ <CheckCheck size={11} />
260
+ ) : (
261
+ <MailOpen size={11} />
262
+ )}
263
+ {message.receipt.label}
264
+ </span>
265
+ ) : null}
266
+ <span className="text-muted-foreground/60 text-xs">{message.date}</span>
267
+ <ChevronUp size={13} className="text-muted-foreground" />
268
+ </span>
269
+ </button>
270
+
271
+ <div className="px-3 pb-3">
272
+ {message.bodyHtml ? (
273
+ <div className={PROSE} dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.bodyHtml) }} />
274
+ ) : (
275
+ <div className={cn(PROSE, "whitespace-pre-line")}>{message.body}</div>
276
+ )}
277
+
278
+ {message.quoted ? (
279
+ <div className="mt-2">
280
+ <button
281
+ type="button"
282
+ onClick={() => setQuoteOpen((v) => !v)}
283
+ className="text-muted-foreground hover:bg-muted rounded px-1.5 text-xs leading-5"
284
+ title={quoteOpen ? "Hide quoted text" : "Show quoted text"}
285
+ >
286
+ •••
287
+ </button>
288
+ {quoteOpen ? (
289
+ <div className="border-border text-muted-foreground mt-1 border-l-2 pl-3 text-[13px]">
290
+ <p className="mb-1" dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.quoted.attr) }} />
291
+ <div className={PROSE} dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.quoted.html) }} />
292
+ </div>
293
+ ) : null}
294
+ </div>
295
+ ) : null}
296
+ </div>
297
+ </div>
298
+ )
299
+ }
300
+
301
+ /* ── Reply composer ─────────────────────────────────────────────────────── */
302
+
303
+ function ReplyComposer({
304
+ thread,
305
+ me,
306
+ replyAll,
307
+ tenantName,
308
+ onClose,
309
+ onSend,
310
+ onDraft,
311
+ }: {
312
+ thread: ConversationThread
313
+ me?: ConvParticipant
314
+ replyAll: boolean
315
+ tenantName?: string
316
+ onClose: () => void
317
+ onSend: (body: string, includeSignature: boolean) => void | Promise<void>
318
+ onDraft: (body: string, includeSignature: boolean) => void
319
+ }) {
320
+ const [body, setBody] = React.useState(thread.draft ?? "")
321
+ const [sig, setSig] = React.useState(true)
322
+ const [preview, setPreview] = React.useState(false)
323
+ const [sending, setSending] = React.useState(false)
324
+ const [sendError, setSendError] = React.useState<string | null>(null)
325
+ const ccList = replyAll ? thread.cc ?? [] : []
326
+ const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`
327
+
328
+ const previewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "")
329
+
330
+ const handleSend = async () => {
331
+ setSending(true)
332
+ setSendError(null)
333
+ try {
334
+ await onSend(body, sig)
335
+ setPreview(false)
336
+ } catch (error) {
337
+ setSendError(error instanceof Error ? error.message : "Could not send this reply. Please try again.")
338
+ } finally {
339
+ setSending(false)
340
+ }
341
+ }
342
+
343
+ return (
344
+ <div data-slot="conv-reply" className="border-border bg-muted/20 rounded-md border p-3">
345
+ <div className="mb-2 flex items-center gap-2">
346
+ {me ? <PersonAvatar person={me} /> : null}
347
+ <span className="flex-1 text-[13px] font-medium">
348
+ {replyAll ? (
349
+ <>
350
+ Reply all{" "}
351
+ <span className="text-muted-foreground font-normal">· {1 + ccList.length} recipients</span>
352
+ </>
353
+ ) : (
354
+ <>
355
+ Reply to <b>{firstName(thread.contact.name)}</b>
356
+ </>
357
+ )}
358
+ </span>
359
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
360
+ <GitMerge size={11} /> Same thread
361
+ </span>
362
+ <button type="button" onClick={onClose} title="Discard reply" className="text-muted-foreground hover:text-foreground">
363
+ <X size={15} />
364
+ </button>
365
+ </div>
366
+
367
+ <div className="border-border mb-2 space-y-1 border-b pb-2 text-[13px]">
368
+ <div className="flex items-center gap-1.5">
369
+ <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">To</span>
370
+ <span className="font-medium">{thread.contact.name}</span>
371
+ <span className="text-muted-foreground/60 truncate text-xs">{thread.contact.email}</span>
372
+ </div>
373
+ {replyAll && ccList.length ? (
374
+ <div className="flex items-start gap-1.5">
375
+ <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">Cc</span>
376
+ <span className="text-muted-foreground text-xs">
377
+ {ccList.map((c) => c.name).join(", ")}
378
+ </span>
379
+ </div>
380
+ ) : null}
381
+ <div className="flex items-center gap-1.5">
382
+ <span className="text-muted-foreground w-12 shrink-0 text-[11px] font-medium">Subject</span>
383
+ <span className="truncate">{subject}</span>
384
+ <span className="text-muted-foreground/60 ml-auto inline-flex items-center gap-1 text-[11px]">
385
+ <Lock size={10} /> on thread
386
+ </span>
387
+ </div>
388
+ </div>
389
+
390
+ <Textarea
391
+ value={body}
392
+ onChange={(e) => setBody(e.target.value)}
393
+ placeholder="Write your reply…"
394
+ className="min-h-28 resize-y text-sm"
395
+ onKeyDown={(e) => {
396
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
397
+ e.preventDefault()
398
+ setPreview(true)
399
+ }
400
+ }}
401
+ />
402
+
403
+ {sig && thread.signature ? (
404
+ <div className="text-muted-foreground mt-2 whitespace-pre-line border-l-2 border-border pl-3 text-[13px]">
405
+ <span className="text-muted-foreground/60 mr-1">--</span>
406
+ {thread.signature}
407
+ </div>
408
+ ) : null}
409
+
410
+ <div className="mt-2 flex flex-wrap items-center gap-2">
411
+ <RichTextToolbar />
412
+ <label className="text-muted-foreground ml-auto inline-flex cursor-pointer items-center gap-1.5 text-[12px]">
413
+ <Switch checked={sig} onCheckedChange={setSig} aria-label="Toggle signature" />
414
+ Signature
415
+ </label>
416
+ <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => setPreview(true)}>
417
+ <Eye size={14} /> Preview
418
+ </Button>
419
+ <Button type="button" size="sm" disabled={sending} onClick={() => setPreview(true)}>
420
+ <Send size={14} /> Send
421
+ </Button>
422
+ </div>
423
+
424
+ <Dialog open={preview} onOpenChange={(open) => { if (!sending) setPreview(open) }}>
425
+ <DialogContent className="max-w-xl">
426
+ <DialogHeader>
427
+ <DialogTitle className="flex items-center gap-1.5 text-[15px]">
428
+ <Eye size={15} /> Preview: this is exactly what sends
429
+ </DialogTitle>
430
+ <DialogDescription>
431
+ Stays on the {subject.replace(/^Re:\s*/i, "")} thread. Gmail keeps it threaded.
432
+ </DialogDescription>
433
+ </DialogHeader>
434
+ <div className="border-border space-y-1 rounded-md border p-3 text-[13px]">
435
+ <div>
436
+ <span className="text-muted-foreground">To </span>
437
+ <b>{thread.contact.name}</b>{" "}
438
+ <span className="text-muted-foreground/60">&lt;{thread.contact.email}&gt;</span>
439
+ </div>
440
+ {replyAll && ccList.length ? (
441
+ <div className="text-muted-foreground">Cc {ccList.map((c) => c.name).join(", ")}</div>
442
+ ) : null}
443
+ <div>
444
+ <span className="text-muted-foreground">Subject </span>
445
+ {subject}
446
+ </div>
447
+ </div>
448
+ <div className={cn(PROSE, "max-h-72 overflow-auto")} dangerouslySetInnerHTML={{ __html: previewHtml }} />
449
+ {sendError ? (
450
+ <p role="alert" className="text-destructive text-sm">
451
+ {sendError}
452
+ </p>
453
+ ) : null}
454
+ <DialogFooter className="sm:justify-between">
455
+ <button
456
+ type="button"
457
+ disabled={sending}
458
+ onClick={() => {
459
+ setPreview(false)
460
+ onDraft(body, sig)
461
+ }}
462
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
463
+ >
464
+ <GmailMark size={14} /> Open draft in Gmail
465
+ </button>
466
+ <span className="flex items-center gap-2">
467
+ <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => setPreview(false)}>
468
+ Keep editing
469
+ </Button>
470
+ <Button
471
+ type="button"
472
+ size="sm"
473
+ disabled={sending}
474
+ onClick={handleSend}
475
+ >
476
+ <Send size={14} /> {sending ? "Sending..." : "Send now"}
477
+ </Button>
478
+ </span>
479
+ </DialogFooter>
480
+ </DialogContent>
481
+ </Dialog>
482
+
483
+ {tenantName ? (
484
+ <p className="text-muted-foreground/70 mt-2 text-[11px]">Sends via Gmail · playbooks stay stopped.</p>
485
+ ) : null}
486
+ </div>
487
+ )
488
+ }
489
+
490
+ /* ── Thread body (messages + footer/composer/done states) ───────────────── */
491
+
492
+ type ThreadMode = "idle" | "replying" | "sent" | "draft"
493
+
494
+ function ThreadBody({
495
+ thread,
496
+ me,
497
+ tenantName,
498
+ onSendReply,
499
+ onCreateGmailDraft,
500
+ onOpenInGmail,
501
+ }: {
502
+ thread: ConversationThread
503
+ me?: ConvParticipant
504
+ tenantName?: string
505
+ onSendReply?: (p: ConversationReplyPayload) => void | Promise<void>
506
+ onCreateGmailDraft?: (p: ConversationReplyPayload) => void
507
+ onOpenInGmail?: (threadId: string) => void
508
+ }) {
509
+ const canReply = thread.canReply !== false
510
+ const hasCc = !!(thread.cc && thread.cc.length)
511
+ const [mode, setMode] = React.useState<ThreadMode>("idle")
512
+ const [replyAll, setReplyAll] = React.useState(false)
513
+ const [expanded, setExpanded] = React.useState<Record<string, boolean>>(() => {
514
+ const o: Record<string, boolean> = {}
515
+ thread.messages.forEach((m, i) => {
516
+ o[m.id] = i === thread.messages.length - 1
517
+ })
518
+ return o
519
+ })
520
+
521
+ const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }))
522
+
523
+ return (
524
+ <div data-slot="conv-thread-body" className="space-y-2">
525
+ {canReply && thread.paused ? (
526
+ <div className="border-status-pending-border bg-status-pending-bg text-status-pending-fg flex items-start gap-2 rounded-md border p-2.5 text-[12px]">
527
+ <Pause size={13} className="mt-0.5 shrink-0" />
528
+ <span>
529
+ <b>Follow-up actions stopped.</b> Your {thread.paused.playbook} next steps won’t send
530
+ automatically while this conversation is live. Continue it in {tenantName ?? "the app"} or Gmail.
531
+ </span>
532
+ </div>
533
+ ) : null}
534
+
535
+ <div className="space-y-1.5">
536
+ {thread.messages.map((m) => (
537
+ <MessageView key={m.id} message={m} expanded={!!expanded[m.id]} onToggle={() => toggle(m.id)} me={me} />
538
+ ))}
539
+ </div>
540
+
541
+ {!canReply ? (
542
+ <div className="border-border bg-muted/30 text-muted-foreground flex items-start gap-2 rounded-md border p-2.5 text-[12px]">
543
+ <Eye size={14} className="mt-0.5 shrink-0" />
544
+ <span>
545
+ <b>Viewing only.</b> You’re not a participant on this thread, so replying is disabled here.
546
+ </span>
547
+ </div>
548
+ ) : null}
549
+
550
+ {canReply && mode === "idle" ? (
551
+ <div className="flex flex-wrap items-center gap-2">
552
+ <Button type="button" size="sm" onClick={() => { setReplyAll(false); setMode("replying") }}>
553
+ <Reply size={15} /> Reply
554
+ </Button>
555
+ {hasCc ? (
556
+ <Button type="button" variant="outline" size="sm" onClick={() => { setReplyAll(true); setMode("replying") }}>
557
+ <ReplyAll size={14} /> Reply all
558
+ </Button>
559
+ ) : null}
560
+ <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
561
+ <GmailMark size={14} /> Open in Gmail
562
+ </Button>
563
+ <span className="text-muted-foreground/70 ml-auto inline-flex items-center gap-1 text-[11px]">
564
+ <GitMerge size={12} /> Stays on this thread
565
+ </span>
566
+ </div>
567
+ ) : null}
568
+
569
+ {canReply && mode === "replying" ? (
570
+ <ReplyComposer
571
+ thread={thread}
572
+ me={me}
573
+ replyAll={replyAll}
574
+ tenantName={tenantName}
575
+ onClose={() => setMode("idle")}
576
+ onSend={async (body, includeSignature) => {
577
+ await onSendReply?.({ threadId: thread.threadId, body, includeSignature, replyAll })
578
+ setMode("sent")
579
+ }}
580
+ onDraft={(body, includeSignature) => {
581
+ onCreateGmailDraft?.({ threadId: thread.threadId, body, includeSignature, replyAll })
582
+ setMode("draft")
583
+ }}
584
+ />
585
+ ) : null}
586
+
587
+ {canReply && mode === "sent" ? (
588
+ <div className="border-status-active-border bg-status-active-bg flex items-center gap-2 rounded-md border p-3 text-[13px]">
589
+ <Check size={16} className="text-status-active-fg shrink-0" />
590
+ <span className="flex-1">
591
+ <b>{replyAll ? "Reply all sent" : "Reply sent"}</b> · added to the thread. Delivered to{" "}
592
+ <b>{thread.contact.name}</b>. This action stays <b>Pending</b>; playbooks remain stopped.
593
+ </span>
594
+ <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
595
+ Done
596
+ </Button>
597
+ </div>
598
+ ) : null}
599
+
600
+ {canReply && mode === "draft" ? (
601
+ <div className="border-border bg-muted/30 flex items-center gap-2 rounded-md border p-3 text-[13px]">
602
+ <GmailMark size={16} />
603
+ <span className="flex-1">
604
+ <b>Draft saved to Gmail.</b> Waiting on the <b>Re: {thread.subject}</b> thread; open it there to finish. Nothing was sent.
605
+ </span>
606
+ <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
607
+ <GmailMark size={14} /> Open in Gmail
608
+ </Button>
609
+ <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
610
+ Done
611
+ </Button>
612
+ </div>
613
+ ) : null}
614
+ </div>
615
+ )
616
+ }
617
+
618
+ /* ── A thread row + its inline reader ───────────────────────────────────── */
619
+
620
+ function ThreadRow({
621
+ thread,
622
+ open,
623
+ onToggleOpen,
624
+ me,
625
+ tenantName,
626
+ onSendReply,
627
+ onCreateGmailDraft,
628
+ onOpenInGmail,
629
+ }: {
630
+ thread: ConversationThread
631
+ open: boolean
632
+ onToggleOpen: () => void
633
+ } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onOpenInGmail">) {
634
+ const status = effectiveStatus(thread)
635
+ const last = thread.messages[thread.messages.length - 1]
636
+ const who = last?.direction === "inbound" ? firstName(last.from.name) : "You"
637
+ const lastSnippet =
638
+ last?.body?.split("\n").find(Boolean) ??
639
+ (last?.bodyHtml ? htmlToTextSnippet(last.bodyHtml, 120) : "")
640
+ const pill = STATUS_PILL[status]
641
+
642
+ return (
643
+ <div data-slot="conv-thread" data-open={open ? "true" : undefined} className="border-border border-b last:border-b-0">
644
+ <button
645
+ type="button"
646
+ onClick={onToggleOpen}
647
+ aria-expanded={open}
648
+ className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-muted/30"
649
+ >
650
+ <span className={cn("size-2 shrink-0 rounded-full", STATUS_DOT[status])} aria-hidden />
651
+ <span className="min-w-0 flex-1">
652
+ <span className="flex items-center gap-2">
653
+ <span className="truncate text-[13px] font-semibold">{thread.subject}</span>
654
+ <span className={cn("shrink-0 rounded border px-1.5 text-[10px] font-medium leading-4", pill.cls)}>
655
+ {pill.label}
656
+ </span>
657
+ </span>
658
+ <span className="text-muted-foreground block truncate text-xs">
659
+ <b className="text-foreground/80">{thread.contact.name}</b> · {who}: {lastSnippet}
660
+ </span>
661
+ </span>
662
+ <span className="text-muted-foreground/60 shrink-0 text-xs">{thread.lastWhen}</span>
663
+ {open ? (
664
+ <ChevronUp size={15} className="text-muted-foreground shrink-0" />
665
+ ) : (
666
+ <ChevronDown size={15} className="text-muted-foreground shrink-0" />
667
+ )}
668
+ </button>
669
+
670
+ {open ? (
671
+ <div className="px-3 pb-3">
672
+ <ThreadBody
673
+ thread={thread}
674
+ me={me}
675
+ tenantName={tenantName}
676
+ onSendReply={onSendReply}
677
+ onCreateGmailDraft={onCreateGmailDraft}
678
+ onOpenInGmail={onOpenInGmail}
679
+ />
680
+ </div>
681
+ ) : null}
682
+ </div>
683
+ )
684
+ }
685
+
686
+ /* ── The hub ─────────────────────────────────────────────────────────────── */
687
+
688
+ function ConversationPanel({
689
+ threads,
690
+ me,
691
+ tenantName,
692
+ onSendReply,
693
+ onCreateGmailDraft,
694
+ onOpenInGmail,
695
+ defaultOpenThreadId,
696
+ className,
697
+ }: ConversationPanelProps) {
698
+ const responded = threads.filter((t) => t.status === "responded" && t.canReply !== false).length
699
+ const awaiting = threads.filter((t) => effectiveStatus(t) === "awaiting").length
700
+ const anyPaused = threads.some((t) => t.paused)
701
+
702
+ const [hubOpen, setHubOpen] = React.useState(true)
703
+ const [openId, setOpenId] = React.useState<string | null>(() => {
704
+ if (defaultOpenThreadId) return defaultOpenThreadId
705
+ const firstResponded = threads.find((t) => t.status === "responded" && t.canReply !== false)
706
+ return firstResponded ? firstResponded.threadId : null
707
+ })
708
+
709
+ if (!threads.length) return null
710
+
711
+ // Header badge state: a responded reply leads; else an awaiting state; else neutral.
712
+ const badge =
713
+ responded > 0
714
+ ? { label: "Email response detected", dot: "bg-status-active-fg", ring: "bg-status-active-fg/30" }
715
+ : awaiting > 0
716
+ ? { label: "Awaiting response", dot: "bg-status-pending-fg", ring: "bg-status-pending-fg/30" }
717
+ : { label: "Conversations", dot: "bg-muted-foreground/50", ring: "bg-muted-foreground/20" }
718
+
719
+ const headTitle =
720
+ responded > 0
721
+ ? `${responded} ${responded === 1 ? "reply needs" : "replies need"} your response`
722
+ : awaiting > 0
723
+ ? `Awaiting response on ${awaiting} ${awaiting === 1 ? "thread" : "threads"}`
724
+ : `${threads.length} email ${threads.length === 1 ? "thread" : "threads"}`
725
+
726
+ return (
727
+ <section
728
+ data-slot="conversation-panel"
729
+ data-responded={responded > 0 ? "true" : undefined}
730
+ className={cn("border-border bg-background overflow-hidden rounded-xl border", className)}
731
+ >
732
+ <button
733
+ type="button"
734
+ onClick={() => setHubOpen((v) => !v)}
735
+ aria-expanded={hubOpen}
736
+ className="flex w-full items-center gap-3 px-3 py-2.5 text-left"
737
+ >
738
+ <span
739
+ data-slot="conversation-badge"
740
+ className={cn(
741
+ "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
742
+ responded > 0
743
+ ? "bg-status-active-bg text-status-active-fg"
744
+ : awaiting > 0
745
+ ? "bg-status-pending-bg text-status-pending-fg"
746
+ : "bg-muted text-muted-foreground"
747
+ )}
748
+ >
749
+ <span className="relative inline-flex size-2">
750
+ <span className={cn("absolute inline-flex h-full w-full animate-ping rounded-full opacity-75", badge.ring)} />
751
+ <span className={cn("relative inline-flex size-2 rounded-full", badge.dot)} />
752
+ </span>
753
+ {badge.label}
754
+ </span>
755
+ <span className="min-w-0 flex-1">
756
+ <span className="block truncate text-[13px] font-semibold">{headTitle}</span>
757
+ <span className="text-muted-foreground block truncate text-xs">
758
+ {threads.length} {threads.length === 1 ? "thread" : "threads"} on this action
759
+ {anyPaused ? <> · <b>playbook stopped</b></> : null}
760
+ </span>
761
+ </span>
762
+ {hubOpen ? (
763
+ <ChevronUp size={16} className="text-muted-foreground shrink-0" />
764
+ ) : (
765
+ <ChevronDown size={16} className="text-muted-foreground shrink-0" />
766
+ )}
767
+ </button>
768
+
769
+ {hubOpen ? (
770
+ <div className="border-border border-t">
771
+ {threads.map((t) => (
772
+ <ThreadRow
773
+ key={t.threadId}
774
+ thread={t}
775
+ open={openId === t.threadId}
776
+ onToggleOpen={() => setOpenId((cur) => (cur === t.threadId ? null : t.threadId))}
777
+ me={me}
778
+ tenantName={tenantName}
779
+ onSendReply={onSendReply}
780
+ onCreateGmailDraft={onCreateGmailDraft}
781
+ onOpenInGmail={onOpenInGmail}
782
+ />
783
+ ))}
784
+ </div>
785
+ ) : null}
786
+ </section>
787
+ )
788
+ }
789
+
790
+ export { ConversationPanel }