@handled-ai/design-system 0.20.23 → 0.20.26

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.
@@ -38,6 +38,7 @@ import {
38
38
  GitMerge,
39
39
  Check,
40
40
  X,
41
+ FileText,
41
42
  } from "lucide-react"
42
43
 
43
44
  import { cn } from "../lib/utils"
@@ -48,6 +49,14 @@ import { Button } from "./button"
48
49
  import { Switch } from "./switch"
49
50
  import { Textarea } from "./textarea"
50
51
  import { RichTextToolbar } from "./rich-text-toolbar"
52
+ import {
53
+ DropdownMenu,
54
+ DropdownMenuContent,
55
+ DropdownMenuItem,
56
+ DropdownMenuLabel,
57
+ DropdownMenuSeparator,
58
+ DropdownMenuTrigger,
59
+ } from "./dropdown-menu"
51
60
  import { EmailBody } from "./email-body"
52
61
  import { decodeEmailDisplayText, emailBodySnippet, formatAddressList, normalizeEmailSender } from "./email-display-helpers"
53
62
  import {
@@ -116,6 +125,21 @@ export interface ConvMessage {
116
125
 
117
126
  export type ConvStatus = "responded" | "awaiting" | "viewing" | "draft"
118
127
 
128
+ /**
129
+ * A reply template offered in the in-composer "Apply template" picker (WIT-970).
130
+ * Presentational: the consumer fills variables for THIS thread's recipient
131
+ * before passing it in, so `body` is composer-ready text. `tags` (e.g. "Reply")
132
+ * are shown as chips so reps can recognize the right template at a glance.
133
+ */
134
+ export interface ConversationReplyTemplate {
135
+ id: string
136
+ name: string
137
+ /** Composer-ready reply body, already personalized by the consumer. */
138
+ body: string
139
+ /** Optional tags shown as chips in the picker for findability. */
140
+ tags?: string[]
141
+ }
142
+
119
143
  export interface ConversationThread {
120
144
  threadId: string
121
145
  subject: string
@@ -141,6 +165,12 @@ export interface ConversationThread {
141
165
  draft?: string
142
166
  /** Signature text appended to replies (plain text). */
143
167
  signature?: string
168
+ /**
169
+ * Reply templates offered in the composer's "Apply template" picker, already
170
+ * personalized for this thread's recipient (WIT-970). Omit/empty hides the
171
+ * picker.
172
+ */
173
+ replyTemplates?: ConversationReplyTemplate[]
144
174
  }
145
175
 
146
176
  export interface ConversationReplyPayload {
@@ -178,6 +208,11 @@ export interface ConversationPanelProps {
178
208
  */
179
209
  onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
180
210
  onOpenInGmail?: (threadId: string) => void
211
+ /**
212
+ * Fired when the rep applies a reply template from the composer picker
213
+ * (WIT-970) — for analytics / template-usage tracking. Optional.
214
+ */
215
+ onApplyReplyTemplate?: (info: { threadId: string; templateId: string }) => void
181
216
  /** Inline-open this thread initially (defaults to the first responded one). */
182
217
  defaultOpenThreadId?: string
183
218
  className?: string
@@ -535,6 +570,7 @@ function ReplyComposer({
535
570
  onSend,
536
571
  onDraft,
537
572
  onPreviewReply,
573
+ onApplyTemplate,
538
574
  draftDisabledReason,
539
575
  }: {
540
576
  thread: ConversationThread
@@ -545,10 +581,19 @@ function ReplyComposer({
545
581
  onSend: (body: string, includeSignature: boolean) => void | Promise<void>
546
582
  onDraft: (body: string, includeSignature: boolean) => void | Promise<void>
547
583
  onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
584
+ onApplyTemplate?: (templateId: string) => void
548
585
  draftDisabledReason?: string | null
549
586
  }) {
550
587
  const [body, setBody] = React.useState(thread.draft ?? "")
588
+ const [appliedTemplate, setAppliedTemplate] = React.useState<string | null>(null)
551
589
  const [sig, setSig] = React.useState(true)
590
+ const replyTemplates = thread.replyTemplates ?? []
591
+
592
+ const applyTemplate = (template: ConversationReplyTemplate) => {
593
+ setBody(template.body)
594
+ setAppliedTemplate(template.name)
595
+ onApplyTemplate?.(template.id)
596
+ }
552
597
  const [preview, setPreview] = React.useState(false)
553
598
  const [previewState, setPreviewState] = React.useState<PreviewState>(IDLE_PREVIEW)
554
599
  const [sending, setSending] = React.useState(false)
@@ -686,6 +731,48 @@ function ReplyComposer({
686
731
  </div>
687
732
  ) : null}
688
733
 
734
+ {replyTemplates.length > 0 ? (
735
+ <div className="mt-2 flex flex-wrap items-center gap-2">
736
+ <DropdownMenu>
737
+ <DropdownMenuTrigger asChild>
738
+ <Button type="button" variant="outline" size="sm" disabled={sending} data-slot="conv-reply-template-trigger">
739
+ <FileText size={14} /> Apply template
740
+ </Button>
741
+ </DropdownMenuTrigger>
742
+ <DropdownMenuContent align="start" className="max-w-xs">
743
+ <DropdownMenuLabel>Reply templates</DropdownMenuLabel>
744
+ <DropdownMenuSeparator />
745
+ {replyTemplates.map((template) => (
746
+ <DropdownMenuItem
747
+ key={template.id}
748
+ onSelect={() => applyTemplate(template)}
749
+ className="flex flex-col items-start gap-0.5"
750
+ >
751
+ <span className="text-[13px] font-medium">{template.name}</span>
752
+ {template.tags && template.tags.length ? (
753
+ <span className="flex flex-wrap gap-1">
754
+ {template.tags.map((tag) => (
755
+ <span
756
+ key={tag}
757
+ className="border-border bg-muted text-muted-foreground rounded border px-1 py-px text-[10px] leading-4"
758
+ >
759
+ {tag}
760
+ </span>
761
+ ))}
762
+ </span>
763
+ ) : null}
764
+ </DropdownMenuItem>
765
+ ))}
766
+ </DropdownMenuContent>
767
+ </DropdownMenu>
768
+ {appliedTemplate ? (
769
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
770
+ <Check size={11} /> Applied “{appliedTemplate}” · edit before sending
771
+ </span>
772
+ ) : null}
773
+ </div>
774
+ ) : null}
775
+
689
776
  <div className="mt-2 flex flex-wrap items-center gap-2">
690
777
  <RichTextToolbar />
691
778
  <label className="text-muted-foreground ml-auto inline-flex cursor-pointer items-center gap-1.5 text-[12px]">
@@ -797,6 +884,7 @@ function ThreadBody({
797
884
  onCreateGmailDraft,
798
885
  onPreviewReply,
799
886
  onOpenInGmail,
887
+ onApplyReplyTemplate,
800
888
  }: {
801
889
  thread: ConversationThread
802
890
  me?: ConvParticipant
@@ -805,6 +893,7 @@ function ThreadBody({
805
893
  onCreateGmailDraft?: (p: ConversationReplyPayload) => void | Promise<void>
806
894
  onPreviewReply?: (p: ConversationReplyPayload) => Promise<ConversationReplyPreview>
807
895
  onOpenInGmail?: (threadId: string) => void
896
+ onApplyReplyTemplate?: ConversationPanelProps["onApplyReplyTemplate"]
808
897
  }) {
809
898
  const canReply = thread.canReply !== false
810
899
  const replyDisabledReason = thread.replyDisabledReason?.trim() || "You are not a participant on this thread, so replying is disabled here."
@@ -885,6 +974,7 @@ function ThreadBody({
885
974
  replyAll={replyAll}
886
975
  tenantName={tenantName}
887
976
  onPreviewReply={onPreviewReply}
977
+ onApplyTemplate={(templateId) => onApplyReplyTemplate?.({ threadId: thread.threadId, templateId })}
888
978
  onClose={() => setMode("idle")}
889
979
  onSend={async (body, includeSignature) => {
890
980
  await onSendReply?.({ threadId: thread.threadId, body, includeSignature, replyAll })
@@ -940,11 +1030,12 @@ function ThreadRow({
940
1030
  onCreateGmailDraft,
941
1031
  onPreviewReply,
942
1032
  onOpenInGmail,
1033
+ onApplyReplyTemplate,
943
1034
  }: {
944
1035
  thread: ConversationThread
945
1036
  open: boolean
946
1037
  onToggleOpen: () => void
947
- } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail">) {
1038
+ } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail" | "onApplyReplyTemplate">) {
948
1039
  const status = effectiveStatus(thread)
949
1040
  const sortedMessages = React.useMemo(() => sortMessagesChronologically(thread.messages), [thread.messages])
950
1041
  const last = sortedMessages[sortedMessages.length - 1]
@@ -997,6 +1088,7 @@ function ThreadRow({
997
1088
  onCreateGmailDraft={onCreateGmailDraft}
998
1089
  onPreviewReply={onPreviewReply}
999
1090
  onOpenInGmail={onOpenInGmail}
1091
+ onApplyReplyTemplate={onApplyReplyTemplate}
1000
1092
  />
1001
1093
  </div>
1002
1094
  ) : null}
@@ -1014,6 +1106,7 @@ function ConversationPanel({
1014
1106
  onCreateGmailDraft,
1015
1107
  onPreviewReply,
1016
1108
  onOpenInGmail,
1109
+ onApplyReplyTemplate,
1017
1110
  defaultOpenThreadId,
1018
1111
  className,
1019
1112
  }: ConversationPanelProps) {
@@ -1148,6 +1241,7 @@ function ConversationPanel({
1148
1241
  onCreateGmailDraft={onCreateGmailDraft}
1149
1242
  onPreviewReply={onPreviewReply}
1150
1243
  onOpenInGmail={onOpenInGmail}
1244
+ onApplyReplyTemplate={onApplyReplyTemplate}
1151
1245
  />
1152
1246
  ))}
1153
1247
  </div>
@@ -214,18 +214,25 @@ export function formatAddressList(input?: string | string[] | null): string {
214
214
  .join(", ")
215
215
  }
216
216
 
217
- export function formatEmailTimestamp(value?: string | Date | null): string | null {
217
+ export function formatEmailTimestamp(
218
+ value?: string | Date | null,
219
+ opts?: { timeZone?: string },
220
+ ): string | null {
218
221
  if (!value) return null
219
222
  const date = value instanceof Date ? value : new Date(value)
220
223
  if (Number.isNaN(date.getTime())) return null
221
224
 
225
+ // Default = the runtime's local timezone (the viewer's, in the browser).
226
+ // Callers that render during SSR/hydration pass timeZone "UTC" for the
227
+ // first paint so server and client HTML match, then re-render local after
228
+ // mount (see useHydrated in timeline-activity).
222
229
  return new Intl.DateTimeFormat("en-US", {
223
230
  month: "short",
224
231
  day: "numeric",
225
232
  year: "numeric",
226
233
  hour: "numeric",
227
234
  minute: "2-digit",
228
- timeZone: "UTC",
235
+ ...(opts?.timeZone ? { timeZone: opts.timeZone } : {}),
229
236
  }).format(date)
230
237
  }
231
238
 
@@ -302,6 +309,18 @@ function makeHtmlSegment(html: string): MessageSegment | null {
302
309
  return { html, visibleText }
303
310
  }
304
311
 
312
+ // Blank-line segments carry no visible text, so they never participate in
313
+ // footer-boundary detection — but their html must survive the rebuild, or a
314
+ // split body loses every intentional blank line between paragraphs (the
315
+ // Gmail wire format marks them as <div><br></div>).
316
+ function makeBlankLineSegment(html: string): MessageSegment {
317
+ return { html, visibleText: "" }
318
+ }
319
+
320
+ function isBlankLineHtml(html: string): boolean {
321
+ return Boolean(html.trim()) && !htmlToVisibleText(html) && !/<(?:img|hr)\b/i.test(html)
322
+ }
323
+
305
324
  function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSegment[] {
306
325
  const containsBr = hasDirectBr(nodes)
307
326
  const chunks: string[][] = [[]]
@@ -315,14 +334,22 @@ function splitInlineNodes(nodes: readonly Node[], wrapper?: Element): MessageSeg
315
334
  chunks[chunks.length - 1]?.push(serializeNode(node))
316
335
  })
317
336
 
318
- return chunks
319
- .map((chunk) => chunk.join(""))
320
- .map((innerHtml) => {
321
- if (wrapper) return wrapHtmlLike(wrapper, innerHtml)
322
- return containsBr ? `<div>${innerHtml}</div>` : innerHtml
323
- })
324
- .map(makeHtmlSegment)
325
- .filter((segment): segment is MessageSegment => Boolean(segment))
337
+ const rendered = chunks.map((chunk) => chunk.join(""))
338
+ const segments: MessageSegment[] = []
339
+ rendered.forEach((innerHtml, index) => {
340
+ const html = wrapper ? wrapHtmlLike(wrapper, innerHtml) : containsBr ? `<div>${innerHtml}</div>` : innerHtml
341
+ const segment = makeHtmlSegment(html)
342
+ if (segment) {
343
+ segments.push(segment)
344
+ return
345
+ }
346
+ // An interior empty chunk sits between two <br>s: an intentional blank
347
+ // line. Leading/trailing empties stay dropped, as before.
348
+ if (containsBr && index > 0 && index < rendered.length - 1) {
349
+ segments.push(makeBlankLineSegment(wrapper ? wrapHtmlLike(wrapper, "<br>") : "<div><br></div>"))
350
+ }
351
+ })
352
+ return segments
326
353
  }
327
354
 
328
355
  function splitElementSegment(element: Element): MessageSegment[] {
@@ -333,6 +360,12 @@ function splitElementSegment(element: Element): MessageSegment[] {
333
360
  return segment ? [segment] : []
334
361
  }
335
362
 
363
+ // A block with no visible content (e.g. <div><br></div>, <p></p>) is a
364
+ // blank-line marker — keep it whole instead of splitting/dropping it.
365
+ if (isBlankLineHtml(element.outerHTML)) {
366
+ return [makeBlankLineSegment(element.outerHTML)]
367
+ }
368
+
336
369
  if (tagName === "div" && hasDirectBlockChild(element)) {
337
370
  const childSegments = splitHtmlNodes(Array.from(element.childNodes))
338
371
  return childSegments.length ? childSegments : ([makeHtmlSegment(element.outerHTML)].filter(Boolean) as MessageSegment[])
@@ -399,9 +432,15 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
399
432
  const pushInline = (inlineHtml: string) => {
400
433
  const chunks = inlineHtml.split(BR_TAG_RE)
401
434
  const hadBr = chunks.length > 1
402
- chunks.forEach((chunk) => {
435
+ chunks.forEach((chunk, index) => {
403
436
  const segment = makeHtmlSegment(hadBr ? `<div>${chunk}</div>` : chunk)
404
- if (segment) segments.push(segment)
437
+ if (segment) {
438
+ segments.push(segment)
439
+ return
440
+ }
441
+ if (hadBr && index > 0 && index < chunks.length - 1) {
442
+ segments.push(makeBlankLineSegment("<div><br /></div>"))
443
+ }
405
444
  })
406
445
  }
407
446
 
@@ -422,13 +461,22 @@ function splitHtmlSegmentsFallback(html: string): MessageSegment[] {
422
461
  const segmentEnd = findMatchingCloseTag(html, tagName, openTagEnd)
423
462
  const blockHtml = html.slice(tagStart, segmentEnd)
424
463
 
425
- if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
464
+ if (isBlankLineHtml(blockHtml)) {
465
+ segments.push(makeBlankLineSegment(blockHtml))
466
+ } else if (SPLITTABLE_BLOCK_TAGS.has(tagName) && BR_TAG_RE.test(blockHtml)) {
426
467
  const openTag = rawOpen
427
468
  const closeTag = `</${tagName}>`
428
469
  const inner = blockHtml.replace(new RegExp(`^${rawOpen.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i"), "").replace(new RegExp(`${closeTag}$`, "i"), "")
429
- inner.split(BR_TAG_RE).forEach((chunk) => {
470
+ const innerChunks = inner.split(BR_TAG_RE)
471
+ innerChunks.forEach((chunk, index) => {
430
472
  const segment = makeHtmlSegment(`${openTag}${chunk}${closeTag}`)
431
- if (segment) segments.push(segment)
473
+ if (segment) {
474
+ segments.push(segment)
475
+ return
476
+ }
477
+ if (index > 0 && index < innerChunks.length - 1) {
478
+ segments.push(makeBlankLineSegment(`${openTag}<br />${closeTag}`))
479
+ }
432
480
  })
433
481
  } else {
434
482
  const segment = makeHtmlSegment(blockHtml)
@@ -392,9 +392,29 @@ function cleanGongText(value?: string | null): string {
392
392
  return decodeEmailDisplayText(value).trim()
393
393
  }
394
394
 
395
- function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
395
+ /**
396
+ * True once the component has mounted on the client. Timestamps render in
397
+ * UTC for SSR + the hydration pass (server and client HTML must match — the
398
+ * server has no idea what timezone the viewer is in), then flip to the
399
+ * viewer's local timezone immediately after mount.
400
+ */
401
+ function useHydrated(): boolean {
402
+ const [hydrated, setHydrated] = React.useState(false)
403
+ React.useEffect(() => {
404
+ setHydrated(true)
405
+ }, [])
406
+ return hydrated
407
+ }
408
+
409
+ type TimestampDisplayOptions = { utcTimestamps?: boolean }
410
+
411
+ function timestampTimeZone(opts?: TimestampDisplayOptions): { timeZone?: string } {
412
+ return opts?.utcTimestamps ? { timeZone: "UTC" } : {}
413
+ }
414
+
415
+ function getTimelineGongCallDisplay(gongCall: TimelineGongCall, opts?: TimestampDisplayOptions) {
396
416
  const title = cleanGongText(gongCall.title)
397
- const startTime = formatEmailTimestamp(gongCall.startTime) ?? ""
417
+ const startTime = formatEmailTimestamp(gongCall.startTime, timestampTimeZone(opts)) ?? ""
398
418
  const duration = formatCallDuration(gongCall.durationSeconds)
399
419
  const direction = cleanGongText(gongCall.direction)
400
420
  const outcome = cleanGongText(gongCall.outcome)
@@ -407,12 +427,12 @@ function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
407
427
  return { title, startTime, duration, direction, outcome, brief, keyPoints, nextSteps, url, snippet }
408
428
  }
409
429
 
410
- function getTimelineEmailDisplay(email: TimelineEmail) {
430
+ function getTimelineEmailDisplay(email: TimelineEmail, opts?: TimestampDisplayOptions) {
411
431
  const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
412
432
  const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
413
433
  const cc = formatAddressList(email.cc) || decodeEmailDisplayText(email.cc ?? "")
414
434
  const bcc = formatAddressList(email.bcc) || decodeEmailDisplayText(email.bcc ?? "")
415
- const date = formatEmailTimestamp(email.date) ?? decodeEmailDisplayText(email.date ?? "")
435
+ const date = formatEmailTimestamp(email.date, timestampTimeZone(opts)) ?? decodeEmailDisplayText(email.date ?? "")
416
436
  const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
417
437
  const bodyText = reactNodeToDisplayText(email.body)
418
438
  const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
@@ -430,7 +450,8 @@ function EmailMetadata({
430
450
  showAllRecipients: boolean
431
451
  setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
432
452
  }) {
433
- const display = getTimelineEmailDisplay(email)
453
+ const hydrated = useHydrated()
454
+ const display = getTimelineEmailDisplay(email, { utcTimestamps: !hydrated })
434
455
  const hasExpandableRecipients = Boolean(display.cc || display.bcc)
435
456
 
436
457
  return (
@@ -847,7 +868,8 @@ function GongCallCard({
847
868
  classes: TimelineVariantClasses
848
869
  }) {
849
870
  const gongCall = event.gongCall as TimelineGongCall
850
- const display = getTimelineGongCallDisplay(gongCall)
871
+ const hydrated = useHydrated()
872
+ const display = getTimelineGongCallDisplay(gongCall, { utcTimestamps: !hydrated })
851
873
 
852
874
  if (variant === "default") {
853
875
  return (