@handled-ai/design-system 0.20.20 → 0.20.22

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { cn } from "../lib/utils"
5
- import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
5
+ import { ChevronDown, ChevronUp, ExternalLink, Phone } from "lucide-react"
6
6
  import { EmailBody as SharedEmailBody } from "./email-body"
7
7
  import {
8
8
  decodeEmailDisplayText,
@@ -54,6 +54,51 @@ export interface TimelineEvent {
54
54
  * existing consumers are unaffected.
55
55
  */
56
56
  bodyHtml?: string
57
+ /**
58
+ * Sender signature HTML, split out of the main body by the consuming app's
59
+ * email pipeline. When present, it is hidden behind a subtle "Show
60
+ * signature" toggle (collapsed by default) so signatures don't add noise to
61
+ * the timeline. Sanitized before rendering. Optional — when absent the body
62
+ * renders exactly as before. Mirrors `signatureHtml` on EmailPreviewCard.
63
+ */
64
+ signatureHtml?: string | null
65
+ /**
66
+ * Quoted / trailing thread content (the "On <date> X wrote:" history) split
67
+ * out of the main body. When present, it is hidden behind a subtle "Show
68
+ * quoted text" toggle (collapsed by default). Sanitized before rendering.
69
+ * Optional — when absent the body renders exactly as before.
70
+ */
71
+ quotedHtml?: string | null
72
+ }
73
+ /**
74
+ * Gong call payload. When present, the card renders a dedicated, first-class
75
+ * Gong call card (mirroring the `email` treatment) instead of generic
76
+ * content: a collapsed duration + brief-snippet preview, and an expanded
77
+ * card with the call title, start time, direction/outcome chips, the AI
78
+ * brief, key points, next steps, and a "View in Gong" link. Opt-in: when
79
+ * absent, existing consumers are unaffected. Every field except `title` is
80
+ * optional/nullable and renders nothing when absent (no empty sections,
81
+ * no "null"/"undefined", no "Invalid Date").
82
+ */
83
+ gongCall?: {
84
+ /** Gong call title. */
85
+ title: string
86
+ /** ISO datetime of the call start. */
87
+ startTime?: string | null
88
+ /** Call length in seconds. Formatted to a human duration when present. */
89
+ durationSeconds?: number | null
90
+ /** Call direction, e.g. "Outbound" / "Inbound". */
91
+ direction?: string | null
92
+ /** Call outcome, e.g. "Connected". */
93
+ outcome?: string | null
94
+ /** Gong AI call brief (plain text, may be multi-paragraph). */
95
+ brief?: string | null
96
+ /** Gong AI key points (plain text, often newline-delimited bullets). */
97
+ keyPoints?: string | null
98
+ /** Gong AI highlights / next steps (plain text). */
99
+ nextSteps?: string | null
100
+ /** Deep link to the call in Gong. */
101
+ url?: string | null
57
102
  }
58
103
  content?: React.ReactNode
59
104
  source?: {
@@ -163,7 +208,7 @@ const TIMELINE_VARIANT_CLASSES: Record<TimelineActivityVariant, TimelineVariantC
163
208
  title: "min-w-0 pr-1 text-[13.5px] font-medium leading-tight text-foreground",
164
209
  time: "shrink-0 whitespace-nowrap pt-px text-[11px] leading-tight text-muted-foreground/60",
165
210
  cardContainer: "overflow-hidden rounded-lg border border-border/70 bg-card",
166
- cardHeader: "flex items-center justify-between border-b border-border/60 bg-muted/30 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70",
211
+ cardHeader: "flex items-start justify-between border-b border-border/60 bg-muted/30 px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70",
167
212
  cardBody: "px-3 py-2.5 text-[13px] leading-relaxed",
168
213
  cardFooter: "border-t border-border/60 bg-muted/10 px-3 py-1.5",
169
214
  collapsedPreview: "flex items-center justify-between gap-2 px-3 py-2 text-[13px] text-muted-foreground",
@@ -245,6 +290,7 @@ function TimelineItem({
245
290
  const [showAllRecipients, setShowAllRecipients] = React.useState(false)
246
291
  const hasContent = !!event.content
247
292
  const hasEmail = !!event.email
293
+ const hasGongCall = !!event.gongCall
248
294
  const classes = TIMELINE_VARIANT_CLASSES[variant]
249
295
 
250
296
  const toneStyle = event.tone ? TONE_CLASSES[event.tone] : null
@@ -275,10 +321,18 @@ function TimelineItem({
275
321
 
276
322
  {event.actor && <ActorByline actor={event.actor} time={event.time} />}
277
323
 
278
- {(hasContent || hasEmail) && (
324
+ {(hasContent || hasEmail || hasGongCall) && (
279
325
  <div className="mt-2">
280
326
  {event.isInteractive ? (
281
- hasEmail ? (
327
+ hasGongCall ? (
328
+ <GongCallCard
329
+ event={event}
330
+ expanded={expanded}
331
+ setExpanded={setExpanded}
332
+ variant={variant}
333
+ classes={classes}
334
+ />
335
+ ) : hasEmail ? (
282
336
  <EmailCard
283
337
  event={event}
284
338
  expanded={expanded}
@@ -310,6 +364,7 @@ function TimelineItem({
310
364
  }
311
365
 
312
366
  type TimelineEmail = NonNullable<TimelineEvent["email"]>
367
+ type TimelineGongCall = NonNullable<TimelineEvent["gongCall"]>
313
368
  type TimelineSource = NonNullable<TimelineEvent["source"]>
314
369
 
315
370
  function reactNodeToDisplayText(value: React.ReactNode): string {
@@ -317,6 +372,41 @@ function reactNodeToDisplayText(value: React.ReactNode): string {
317
372
  return ""
318
373
  }
319
374
 
375
+ /**
376
+ * Formats a call length in seconds to a compact human duration:
377
+ * "38 min", ">= 60 min" → "1 h 12 min". Returns "" for absent/invalid
378
+ * input so callers can omit the chip entirely (never render "0 min").
379
+ */
380
+ function formatCallDuration(durationSeconds?: number | null): string {
381
+ if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return ""
382
+ const totalMinutes = Math.round(durationSeconds / 60)
383
+ if (totalMinutes < 1) return "< 1 min"
384
+ if (totalMinutes < 60) return `${totalMinutes} min`
385
+ const hours = Math.floor(totalMinutes / 60)
386
+ const minutes = totalMinutes % 60
387
+ return minutes === 0 ? `${hours} h` : `${hours} h ${minutes} min`
388
+ }
389
+
390
+ function cleanGongText(value?: string | null): string {
391
+ if (!value) return ""
392
+ return decodeEmailDisplayText(value).trim()
393
+ }
394
+
395
+ function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
396
+ const title = cleanGongText(gongCall.title)
397
+ const startTime = formatEmailTimestamp(gongCall.startTime) ?? ""
398
+ const duration = formatCallDuration(gongCall.durationSeconds)
399
+ const direction = cleanGongText(gongCall.direction)
400
+ const outcome = cleanGongText(gongCall.outcome)
401
+ const brief = cleanGongText(gongCall.brief)
402
+ const keyPoints = cleanGongText(gongCall.keyPoints)
403
+ const nextSteps = cleanGongText(gongCall.nextSteps)
404
+ const url = gongCall.url?.trim() || ""
405
+ const snippet = brief.split("\n").find((line) => line.trim())?.trim() ?? ""
406
+
407
+ return { title, startTime, duration, direction, outcome, brief, keyPoints, nextSteps, url, snippet }
408
+ }
409
+
320
410
  function getTimelineEmailDisplay(email: TimelineEmail) {
321
411
  const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
322
412
  const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
@@ -326,8 +416,9 @@ function getTimelineEmailDisplay(email: TimelineEmail) {
326
416
  const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
327
417
  const bodyText = reactNodeToDisplayText(email.body)
328
418
  const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
419
+ const hasRecipients = Boolean(to || cc || bcc)
329
420
 
330
- return { sender, to, cc, bcc, date, subject, bodyText, snippet }
421
+ return { sender, to, cc, bcc, date, subject, bodyText, snippet, hasRecipients }
331
422
  }
332
423
 
333
424
  function EmailMetadata({
@@ -340,9 +431,13 @@ function EmailMetadata({
340
431
  setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
341
432
  }) {
342
433
  const display = getTimelineEmailDisplay(email)
434
+ const hasExpandableRecipients = Boolean(display.cc || display.bcc)
343
435
 
344
436
  return (
345
437
  <>
438
+ {/* Row 1: sender name + email, with the date right-aligned. The `gap-4`
439
+ guarantees whitespace between the sender block and the date so they
440
+ never visually jam together. */}
346
441
  <div className="flex items-center justify-between gap-4">
347
442
  <div className="flex min-w-0 items-baseline gap-1.5">
348
443
  <span className="font-semibold text-foreground text-[13px] whitespace-nowrap">{display.sender.name}</span>
@@ -354,36 +449,94 @@ function EmailMetadata({
354
449
  <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.date}</span>
355
450
  ) : null}
356
451
  </div>
357
- <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
358
- <span className="truncate">
359
- To {display.to || "no recipient yet"}
360
- {!showAllRecipients && (display.cc || display.bcc) ? (
361
- <>, ...</>
362
- ) : null}
363
- {showAllRecipients && display.cc ? (
364
- <>, <span className="text-muted-foreground/40">cc</span> {display.cc}</>
452
+ {/* Recipient lines live on their own full-width, stacked rows below the
453
+ sender/date row. Each line truncates independently. The To-segment is
454
+ suppressed entirely when there is no recipient data (history emails)
455
+ rather than rendering a meaningless "no recipient yet" placeholder. */}
456
+ {display.hasRecipients ? (
457
+ <div className="mt-0.5 text-xs text-muted-foreground">
458
+ <div className="flex items-center gap-1">
459
+ <span className="min-w-0 flex-1 truncate">
460
+ {display.to ? (
461
+ <>To {display.to}</>
462
+ ) : (
463
+ <>
464
+ <span className="text-muted-foreground/40">cc</span> {display.cc || display.bcc}
465
+ </>
466
+ )}
467
+ {display.to && !showAllRecipients && hasExpandableRecipients ? (
468
+ <>, &hellip;</>
469
+ ) : null}
470
+ </span>
471
+ {hasExpandableRecipients ? (
472
+ <button
473
+ type="button"
474
+ onClick={(e) => {
475
+ e.stopPropagation()
476
+ setShowAllRecipients((prev) => !prev)
477
+ }}
478
+ className="shrink-0 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
479
+ aria-label={showAllRecipients ? "Hide cc and bcc" : "Show cc and bcc"}
480
+ >
481
+ <ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
482
+ </button>
483
+ ) : null}
484
+ </div>
485
+ {showAllRecipients && display.to && display.cc ? (
486
+ <div className="truncate">
487
+ <span className="text-muted-foreground/40">cc</span> {display.cc}
488
+ </div>
365
489
  ) : null}
366
490
  {showAllRecipients && display.bcc ? (
367
- <> <span className="text-muted-foreground/40">bcc</span> {display.bcc}</>
491
+ <div className="truncate">
492
+ <span className="text-muted-foreground/40">bcc</span> {display.bcc}
493
+ </div>
368
494
  ) : null}
369
- </span>
370
- {(display.cc || display.bcc) ? (
371
- <button
372
- type="button"
373
- onClick={(e) => {
374
- e.stopPropagation()
375
- setShowAllRecipients((prev) => !prev)
376
- }}
377
- className="shrink-0 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
378
- >
379
- <ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
380
- </button>
381
- ) : null}
382
- </div>
495
+ </div>
496
+ ) : null}
383
497
  </>
384
498
  )
385
499
  }
386
500
 
501
+ function SuppressedHtmlSection({
502
+ html,
503
+ showLabel,
504
+ hideLabel,
505
+ slot,
506
+ }: {
507
+ html: string
508
+ showLabel: string
509
+ hideLabel: string
510
+ slot: string
511
+ }) {
512
+ const [open, setOpen] = React.useState(false)
513
+
514
+ return (
515
+ <div className="mt-2" data-slot={slot}>
516
+ <button
517
+ type="button"
518
+ onClick={(e) => {
519
+ e.stopPropagation()
520
+ setOpen((value) => !value)
521
+ }}
522
+ aria-expanded={open}
523
+ className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
524
+ >
525
+ {open ? hideLabel : showLabel}
526
+ <ChevronDown className={cn("h-3 w-3 transition-transform", open && "rotate-180")} />
527
+ </button>
528
+ {open ? (
529
+ <div
530
+ data-slot={`${slot}-content`}
531
+ className="mt-2 border-l-2 border-border pl-3 text-sm leading-relaxed text-muted-foreground"
532
+ >
533
+ <SharedEmailBody html={html} variant="history" collapseDetails={false} />
534
+ </div>
535
+ ) : null}
536
+ </div>
537
+ )
538
+ }
539
+
387
540
  function TimelineEmailBody({ email }: { email: TimelineEmail }) {
388
541
  const display = getTimelineEmailDisplay(email)
389
542
  const bodyFallback = display.bodyText || (typeof email.body === "string" ? email.body : "")
@@ -402,7 +555,35 @@ function TimelineEmailBody({ email }: { email: TimelineEmail }) {
402
555
  />
403
556
  )
404
557
 
405
- return email.bodyHtml ? <div data-slot="timeline-email-html">{body}</div> : body
558
+ const hasSignature = Boolean(email.signatureHtml && email.signatureHtml.trim())
559
+ const hasQuoted = Boolean(email.quotedHtml && email.quotedHtml.trim())
560
+
561
+ // Fast path: no signature/quoted slots → identical output to before.
562
+ if (!hasSignature && !hasQuoted) {
563
+ return email.bodyHtml ? <div data-slot="timeline-email-html">{body}</div> : body
564
+ }
565
+
566
+ return (
567
+ <div data-slot={email.bodyHtml ? "timeline-email-html" : undefined}>
568
+ {body}
569
+ {hasSignature ? (
570
+ <SuppressedHtmlSection
571
+ html={email.signatureHtml as string}
572
+ showLabel="Show signature"
573
+ hideLabel="Hide signature"
574
+ slot="timeline-email-signature"
575
+ />
576
+ ) : null}
577
+ {hasQuoted ? (
578
+ <SuppressedHtmlSection
579
+ html={email.quotedHtml as string}
580
+ showLabel="Show quoted text"
581
+ hideLabel="Hide quoted text"
582
+ slot="timeline-email-quoted"
583
+ />
584
+ ) : null}
585
+ </div>
586
+ )
406
587
  }
407
588
 
408
589
  function renderDecodedPreview(preview?: React.ReactNode): React.ReactNode {
@@ -428,7 +609,7 @@ function CollapsedEmailPreview({
428
609
 
429
610
  return (
430
611
  <div className={className} onClick={onClick}>
431
- <span className="line-clamp-1 pr-3 text-[13px]">
612
+ <span className="line-clamp-1 min-w-0 flex-1 pr-3 text-[13px]">
432
613
  <span className="text-muted-foreground">{display?.sender.name}</span>
433
614
  <span className="mx-1.5 text-muted-foreground/40">&middot;</span>
434
615
  {display?.subject ? (
@@ -462,6 +643,34 @@ function ShowLessButton({
462
643
  )
463
644
  }
464
645
 
646
+ /**
647
+ * Top-of-card collapse affordance. Long expanded emails are tedious to collapse
648
+ * from the bottom alone, so the header row also carries a "Show less" control.
649
+ */
650
+ function CollapseTopButton({
651
+ onClick,
652
+ className,
653
+ }: {
654
+ onClick: React.MouseEventHandler<HTMLButtonElement>
655
+ className?: string
656
+ }) {
657
+ return (
658
+ <button
659
+ type="button"
660
+ onClick={onClick}
661
+ aria-label="Show less"
662
+ data-slot="timeline-collapse-top"
663
+ className={cn(
664
+ "shrink-0 inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground",
665
+ className,
666
+ )}
667
+ >
668
+ <span className="sr-only sm:not-sr-only">Show less</span>
669
+ <ChevronUp className="h-3 w-3" />
670
+ </button>
671
+ )
672
+ }
673
+
465
674
  function SourceAction({
466
675
  source,
467
676
  onSourceClick,
@@ -499,6 +708,256 @@ function SourceAction({
499
708
  )
500
709
  }
501
710
 
711
+ function GongLinkAction({
712
+ url,
713
+ className,
714
+ }: {
715
+ url: string
716
+ className: string
717
+ }) {
718
+ return (
719
+ <a
720
+ href={url}
721
+ target="_blank"
722
+ rel="noopener noreferrer"
723
+ onClick={(e) => e.stopPropagation()}
724
+ className={className}
725
+ data-slot="timeline-gong-link"
726
+ >
727
+ View in Gong
728
+ <ExternalLink className="h-3 w-3" />
729
+ </a>
730
+ )
731
+ }
732
+
733
+ /**
734
+ * The expanded Gong call card body — call title + start time header, optional
735
+ * direction/outcome chips, the AI brief as primary body text, and labeled
736
+ * Key points / Next steps subsections. Every section renders only when its
737
+ * content is present.
738
+ */
739
+ function TimelineGongCallBody({
740
+ display,
741
+ labelClassName,
742
+ }: {
743
+ display: ReturnType<typeof getTimelineGongCallDisplay>
744
+ labelClassName: string
745
+ }) {
746
+ return (
747
+ <div className="space-y-3" data-slot="timeline-gong-body">
748
+ {display.brief ? (
749
+ <div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
750
+ {display.brief}
751
+ </div>
752
+ ) : null}
753
+ {display.keyPoints ? (
754
+ <div data-slot="timeline-gong-key-points">
755
+ <div className={labelClassName}>Key points</div>
756
+ <div className="mt-1 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
757
+ {display.keyPoints}
758
+ </div>
759
+ </div>
760
+ ) : null}
761
+ {display.nextSteps ? (
762
+ <div data-slot="timeline-gong-next-steps">
763
+ <div className={labelClassName}>Next steps</div>
764
+ <div className="mt-1 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
765
+ {display.nextSteps}
766
+ </div>
767
+ </div>
768
+ ) : null}
769
+ </div>
770
+ )
771
+ }
772
+
773
+ function GongCallHeader({
774
+ display,
775
+ }: {
776
+ display: ReturnType<typeof getTimelineGongCallDisplay>
777
+ }) {
778
+ return (
779
+ <>
780
+ <div className="flex items-start justify-between gap-4">
781
+ <div className="flex min-w-0 items-center gap-1.5">
782
+ <Phone className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
783
+ <span className="min-w-0 truncate font-semibold text-foreground text-[13px]">{display.title}</span>
784
+ </div>
785
+ {display.startTime ? (
786
+ <span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.startTime}</span>
787
+ ) : null}
788
+ </div>
789
+ {display.duration || display.direction || display.outcome ? (
790
+ <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
791
+ {display.duration ? <span data-slot="timeline-gong-duration">{display.duration}</span> : null}
792
+ {display.direction ? (
793
+ <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{display.direction}</span>
794
+ ) : null}
795
+ {display.outcome ? (
796
+ <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{display.outcome}</span>
797
+ ) : null}
798
+ </div>
799
+ ) : null}
800
+ </>
801
+ )
802
+ }
803
+
804
+ function CollapsedGongCallPreview({
805
+ display,
806
+ className,
807
+ actionClassName,
808
+ onClick,
809
+ }: {
810
+ display: ReturnType<typeof getTimelineGongCallDisplay>
811
+ className: string
812
+ actionClassName: string
813
+ onClick?: () => void
814
+ }) {
815
+ return (
816
+ <div className={className} onClick={onClick}>
817
+ <span className="line-clamp-1 min-w-0 flex-1 pr-3 text-[13px] text-muted-foreground">
818
+ {display.duration ? (
819
+ <>
820
+ <span className="inline-flex items-center gap-1">
821
+ <Phone className="h-3 w-3" />
822
+ {display.duration}
823
+ </span>
824
+ {display.snippet ? <span className="mx-1.5 text-muted-foreground/40">&middot;</span> : null}
825
+ </>
826
+ ) : null}
827
+ {display.snippet ? <span>{display.snippet}</span> : null}
828
+ </span>
829
+ <button type="button" className={actionClassName}>
830
+ Expand <ChevronDown className="h-3 w-3" />
831
+ </button>
832
+ </div>
833
+ )
834
+ }
835
+
836
+ function GongCallCard({
837
+ event,
838
+ expanded,
839
+ setExpanded,
840
+ variant,
841
+ classes,
842
+ }: {
843
+ event: TimelineEvent
844
+ expanded: boolean
845
+ setExpanded: React.Dispatch<React.SetStateAction<boolean>>
846
+ variant: TimelineActivityVariant
847
+ classes: TimelineVariantClasses
848
+ }) {
849
+ const gongCall = event.gongCall as TimelineGongCall
850
+ const display = getTimelineGongCallDisplay(gongCall)
851
+
852
+ if (variant === "default") {
853
+ return (
854
+ <div className={classes.cardContainer} data-variant={variant} data-slot="timeline-gong-card">
855
+ <div
856
+ className={cn(
857
+ "px-3 py-2.5 text-sm",
858
+ !expanded && "cursor-pointer hover:bg-muted/30 transition-colors",
859
+ )}
860
+ onClick={() => !expanded && setExpanded(true)}
861
+ >
862
+ {expanded ? (
863
+ <div className="space-y-3">
864
+ <div className="flex items-start justify-between gap-3">
865
+ <div className="min-w-0 flex-1">
866
+ <GongCallHeader display={display} />
867
+ </div>
868
+ <CollapseTopButton
869
+ onClick={(e) => {
870
+ e.stopPropagation()
871
+ setExpanded(false)
872
+ }}
873
+ className="mt-0.5"
874
+ />
875
+ </div>
876
+
877
+ <TimelineGongCallBody
878
+ display={display}
879
+ labelClassName="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70"
880
+ />
881
+
882
+ <div className="mt-2 flex items-center gap-3">
883
+ {display.url ? (
884
+ <GongLinkAction
885
+ url={display.url}
886
+ className="mr-auto inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
887
+ />
888
+ ) : null}
889
+ <ShowLessButton
890
+ onClick={(e) => {
891
+ e.stopPropagation()
892
+ setExpanded(false)
893
+ }}
894
+ className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
895
+ />
896
+ </div>
897
+ </div>
898
+ ) : (
899
+ <CollapsedGongCallPreview
900
+ display={display}
901
+ className="flex items-center justify-between gap-2 text-muted-foreground"
902
+ actionClassName="flex shrink-0 items-center gap-1 text-[11px] font-semibold uppercase tracking-wider transition-colors hover:text-foreground"
903
+ />
904
+ )}
905
+ </div>
906
+ </div>
907
+ )
908
+ }
909
+
910
+ return (
911
+ <div className={classes.cardContainer} data-variant={variant} data-slot="timeline-gong-card">
912
+ {expanded ? (
913
+ <>
914
+ <div className={classes.cardHeader} data-slot="timeline-card-header">
915
+ <div className="min-w-0 flex-1 normal-case tracking-normal">
916
+ <GongCallHeader display={display} />
917
+ </div>
918
+ <CollapseTopButton
919
+ onClick={(e) => {
920
+ e.stopPropagation()
921
+ setExpanded(false)
922
+ }}
923
+ className="ml-3 normal-case tracking-normal"
924
+ />
925
+ </div>
926
+
927
+ <div className={classes.cardBody} data-slot="timeline-card-body">
928
+ <TimelineGongCallBody
929
+ display={display}
930
+ labelClassName="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70"
931
+ />
932
+ </div>
933
+
934
+ <div
935
+ className={cn(classes.cardFooter, classes.actionLinkRow, display.url ? "justify-between" : "justify-end")}
936
+ data-slot="timeline-card-footer"
937
+ >
938
+ {display.url ? <GongLinkAction url={display.url} className={classes.actionLink} /> : null}
939
+ <ShowLessButton
940
+ type="button"
941
+ onClick={(e) => {
942
+ e.stopPropagation()
943
+ setExpanded(false)
944
+ }}
945
+ className={classes.actionLink}
946
+ />
947
+ </div>
948
+ </>
949
+ ) : (
950
+ <CollapsedGongCallPreview
951
+ display={display}
952
+ className={cn(classes.collapsedPreview, "cursor-pointer hover:bg-muted/30 transition-colors")}
953
+ actionClassName={cn(classes.actionLink, "shrink-0")}
954
+ onClick={() => setExpanded(true)}
955
+ />
956
+ )}
957
+ </div>
958
+ )
959
+ }
960
+
502
961
  function EmailCard({
503
962
  event,
504
963
  expanded,
@@ -528,11 +987,20 @@ function EmailCard({
528
987
  >
529
988
  {expanded && event.email ? (
530
989
  <div className="space-y-3">
531
- <div>
532
- <EmailMetadata
533
- email={event.email}
534
- showAllRecipients={showAllRecipients}
535
- setShowAllRecipients={setShowAllRecipients}
990
+ <div className="flex items-start justify-between gap-3">
991
+ <div className="min-w-0 flex-1">
992
+ <EmailMetadata
993
+ email={event.email}
994
+ showAllRecipients={showAllRecipients}
995
+ setShowAllRecipients={setShowAllRecipients}
996
+ />
997
+ </div>
998
+ <CollapseTopButton
999
+ onClick={(e) => {
1000
+ e.stopPropagation()
1001
+ setExpanded(false)
1002
+ }}
1003
+ className="mt-0.5"
536
1004
  />
537
1005
  </div>
538
1006
 
@@ -579,10 +1047,19 @@ function EmailCard({
579
1047
  {expanded && event.email ? (
580
1048
  <>
581
1049
  <div className={classes.cardHeader} data-slot="timeline-card-header">
582
- <EmailMetadata
583
- email={event.email}
584
- showAllRecipients={showAllRecipients}
585
- setShowAllRecipients={setShowAllRecipients}
1050
+ <div className="min-w-0 flex-1 normal-case tracking-normal">
1051
+ <EmailMetadata
1052
+ email={event.email}
1053
+ showAllRecipients={showAllRecipients}
1054
+ setShowAllRecipients={setShowAllRecipients}
1055
+ />
1056
+ </div>
1057
+ <CollapseTopButton
1058
+ onClick={(e) => {
1059
+ e.stopPropagation()
1060
+ setExpanded(false)
1061
+ }}
1062
+ className="ml-3 normal-case tracking-normal"
586
1063
  />
587
1064
  </div>
588
1065