@handled-ai/design-system 0.20.21 → 0.20.23

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.
@@ -416,4 +416,333 @@ describe("TimelineActivity", () => {
416
416
  expectNoVisibleEscapeArtifacts(container.textContent ?? "")
417
417
  })
418
418
 
419
+ // --- Empty-recipient suppression (history emails) ---
420
+
421
+ it.each(["default", "case-panel"] as const)(
422
+ "suppresses the To-segment in the expanded %s card when there is no recipient",
423
+ (variant) => {
424
+ const event = minimal({
425
+ isInteractive: true,
426
+ defaultExpanded: true,
427
+ email: {
428
+ from: "Priya Raman",
429
+ to: "",
430
+ subject: "Synced email",
431
+ body: "historical body",
432
+ },
433
+ })
434
+
435
+ const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
436
+
437
+ expect(container.textContent).not.toContain("no recipient yet")
438
+ // No "To " label renders at all when recipients are empty.
439
+ expect(container.textContent).not.toMatch(/\bTo\s/)
440
+ },
441
+ )
442
+
443
+ it("suppresses the To-segment in the collapsed preview when there is no recipient", () => {
444
+ const event = minimal({
445
+ isInteractive: true,
446
+ defaultExpanded: false,
447
+ email: {
448
+ from: "Priya Raman",
449
+ to: "",
450
+ subject: "Synced email",
451
+ body: "historical body",
452
+ },
453
+ })
454
+
455
+ const { container } = render(<TimelineActivity events={[event]} />)
456
+
457
+ expect(container.textContent).not.toContain("no recipient yet")
458
+ // Collapsed preview shows sender + subject + snippet, never a To-segment.
459
+ expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
460
+ })
461
+
462
+ it("still renders the recipient on the expanded card when present, stacked below the sender row", () => {
463
+ const event = minimal({
464
+ isInteractive: true,
465
+ defaultExpanded: true,
466
+ email: {
467
+ from: "Priya Raman",
468
+ fromEmail: "priya@example.com",
469
+ to: "Recipient Name <recipient@example.com>",
470
+ date: "2026-06-08T15:30:00.000Z",
471
+ subject: "Re: hi",
472
+ body: "body",
473
+ },
474
+ })
475
+
476
+ const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
477
+ const header = container.querySelector('[data-slot="timeline-card-header"]')!
478
+
479
+ expect(header.textContent).toContain("Priya Raman")
480
+ expect(header.textContent).toContain("To Recipient Name <recipient@example.com>")
481
+ // Date and the To-line are in separate block rows (no jammed concatenation).
482
+ expect(header.textContent).not.toMatch(/\d(?:AM|PM)To /)
483
+ })
484
+
485
+ // --- Top collapse affordance ---
486
+
487
+ it.each(["default", "case-panel"] as const)(
488
+ "collapses the expanded %s email card from the top affordance",
489
+ (variant) => {
490
+ const event = minimal({
491
+ isInteractive: true,
492
+ defaultExpanded: true,
493
+ email: {
494
+ from: "Priya Raman",
495
+ to: "Dana Okafor",
496
+ subject: "Re: hi",
497
+ body: "expanded body content",
498
+ },
499
+ })
500
+
501
+ const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
502
+
503
+ // The top collapse control is present while expanded.
504
+ const topCollapse = container.querySelector('[data-slot="timeline-collapse-top"]')!
505
+ expect(topCollapse).not.toBeNull()
506
+ // While expanded both the top and bottom "Show less" affordances exist.
507
+ expect(screen.getAllByRole("button", { name: /Show less/i }).length).toBe(2)
508
+
509
+ fireEvent.click(topCollapse)
510
+
511
+ // After collapsing, the expanded affordances are gone and the collapsed
512
+ // preview (Expand button) returns.
513
+ expect(container.querySelector('[data-slot="timeline-collapse-top"]')).toBeNull()
514
+ expect(screen.queryAllByRole("button", { name: /Show less/i }).length).toBe(0)
515
+ expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
516
+ },
517
+ )
518
+
519
+ // --- Signature / quoted-content suppression ---
520
+
521
+ it("hides signatureHtml behind a 'Show signature' toggle (collapsed by default)", () => {
522
+ const event = minimal({
523
+ isInteractive: true,
524
+ defaultExpanded: true,
525
+ email: {
526
+ from: "Priya Raman",
527
+ to: "Dana Okafor",
528
+ subject: "Re: hi",
529
+ body: "main body",
530
+ bodyHtml: "<p>main body</p>",
531
+ signatureHtml: "<p>Priya Raman | VP of Sales | 555-1234</p>",
532
+ },
533
+ })
534
+
535
+ const { container } = render(<TimelineActivity events={[event]} />)
536
+
537
+ // Signature content is not visible by default.
538
+ expect(container.textContent).not.toContain("VP of Sales")
539
+ const toggle = screen.getByRole("button", { name: /Show signature/i })
540
+ expect(toggle).toBeTruthy()
541
+
542
+ fireEvent.click(toggle)
543
+ expect(container.querySelector('[data-slot="timeline-email-signature-content"]')).not.toBeNull()
544
+ expect(container.textContent).toContain("VP of Sales")
545
+ expect(screen.getByRole("button", { name: /Hide signature/i })).toBeTruthy()
546
+ })
547
+
548
+ it("hides quotedHtml behind a 'Show quoted text' toggle (collapsed by default)", () => {
549
+ const event = minimal({
550
+ isInteractive: true,
551
+ defaultExpanded: true,
552
+ email: {
553
+ from: "Priya Raman",
554
+ to: "Dana Okafor",
555
+ subject: "Re: hi",
556
+ body: "main body",
557
+ bodyHtml: "<p>main body</p>",
558
+ quotedHtml: "<blockquote>On Mon, earlier message text</blockquote>",
559
+ },
560
+ })
561
+
562
+ const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
563
+
564
+ expect(container.textContent).not.toContain("earlier message text")
565
+ const toggle = screen.getByRole("button", { name: /Show quoted text/i })
566
+ expect(toggle).toBeTruthy()
567
+
568
+ fireEvent.click(toggle)
569
+ expect(container.querySelector('[data-slot="timeline-email-quoted-content"]')).not.toBeNull()
570
+ expect(container.textContent).toContain("earlier message text")
571
+ })
572
+
573
+ it("renders the plain email path unchanged when signatureHtml/quotedHtml are absent", () => {
574
+ const event = minimal({
575
+ isInteractive: true,
576
+ defaultExpanded: true,
577
+ email: {
578
+ from: "Priya Raman",
579
+ to: "Dana Okafor",
580
+ subject: "Re: hi",
581
+ body: "just the body",
582
+ bodyHtml: "<p>just the body</p>",
583
+ },
584
+ })
585
+
586
+ const { container } = render(<TimelineActivity events={[event]} />)
587
+
588
+ expect(container.querySelector('[data-slot="timeline-email-signature"]')).toBeNull()
589
+ expect(container.querySelector('[data-slot="timeline-email-quoted"]')).toBeNull()
590
+ expect(screen.queryByRole("button", { name: /Show signature/i })).toBeNull()
591
+ expect(screen.queryByRole("button", { name: /Show quoted text/i })).toBeNull()
592
+ // The HTML body still renders via the existing slot.
593
+ expect(container.querySelector('[data-slot="timeline-email-html"]')).not.toBeNull()
594
+ expect(container.textContent).toContain("just the body")
595
+ })
596
+
597
+ // --- Gong call card ---
598
+
599
+ function gongEvent(overrides: Partial<NonNullable<TimelineEvent["gongCall"]>> = {}): TimelineEvent {
600
+ return minimal({
601
+ isInteractive: true,
602
+ defaultExpanded: true,
603
+ gongCall: {
604
+ title: "Discovery call with Acme",
605
+ startTime: "2026-06-08T15:30:00.000Z",
606
+ durationSeconds: 2280, // 38 min
607
+ direction: "Outbound",
608
+ outcome: "Connected",
609
+ brief: "Discussed Q3 expansion plans.\nBudget approved by finance.",
610
+ keyPoints: "- Wants SSO\n- 200 seats",
611
+ nextSteps: "Send proposal by Friday",
612
+ url: "https://app.gong.io/call?id=abc123",
613
+ ...overrides,
614
+ },
615
+ })
616
+ }
617
+
618
+ it.each(["default", "case-panel"] as const)(
619
+ "renders the Gong call title, duration, and brief in the expanded %s card",
620
+ (variant) => {
621
+ const { container } = render(<TimelineActivity events={[gongEvent()]} variant={variant} />)
622
+
623
+ const card = container.querySelector('[data-slot="timeline-gong-card"]')
624
+ expect(card).not.toBeNull()
625
+ expect(container.textContent).toContain("Discovery call with Acme")
626
+ expect(container.querySelector('[data-slot="timeline-gong-duration"]')?.textContent).toContain("38 min")
627
+ expect(container.textContent).toContain("Discussed Q3 expansion plans.")
628
+ expect(container.textContent).toContain("Outbound")
629
+ expect(container.textContent).toContain("Connected")
630
+ },
631
+ )
632
+
633
+ it("formats long durations as hours and minutes", () => {
634
+ const { container } = render(
635
+ <TimelineActivity events={[gongEvent({ durationSeconds: 4320 })]} />, // 72 min
636
+ )
637
+ expect(container.querySelector('[data-slot="timeline-gong-duration"]')?.textContent).toContain("1 h 12 min")
638
+ })
639
+
640
+ it("renders Key points and Next steps sections only when present", () => {
641
+ const { container } = render(<TimelineActivity events={[gongEvent()]} />)
642
+ expect(container.querySelector('[data-slot="timeline-gong-key-points"]')).not.toBeNull()
643
+ expect(container.querySelector('[data-slot="timeline-gong-next-steps"]')).not.toBeNull()
644
+ expect(container.textContent).toContain("Key points")
645
+ expect(container.textContent).toContain("Wants SSO")
646
+ expect(container.textContent).toContain("Next steps")
647
+ expect(container.textContent).toContain("Send proposal by Friday")
648
+ })
649
+
650
+ it("omits Key points and Next steps sections when those fields are absent", () => {
651
+ const { container } = render(
652
+ <TimelineActivity events={[gongEvent({ keyPoints: null, nextSteps: undefined })]} />,
653
+ )
654
+ expect(container.querySelector('[data-slot="timeline-gong-key-points"]')).toBeNull()
655
+ expect(container.querySelector('[data-slot="timeline-gong-next-steps"]')).toBeNull()
656
+ expect(container.textContent).not.toContain("Key points")
657
+ expect(container.textContent).not.toContain("Next steps")
658
+ })
659
+
660
+ it("renders a 'View in Gong' link with href + new-tab attributes when url is present", () => {
661
+ render(<TimelineActivity events={[gongEvent()]} />)
662
+ const link = screen.getByRole("link", { name: /View in Gong/i })
663
+ expect(link).toHaveAttribute("href", "https://app.gong.io/call?id=abc123")
664
+ expect(link).toHaveAttribute("target", "_blank")
665
+ expect(link).toHaveAttribute("rel", "noopener noreferrer")
666
+ })
667
+
668
+ it("omits the 'View in Gong' link when url is absent", () => {
669
+ render(<TimelineActivity events={[gongEvent({ url: null })]} />)
670
+ expect(screen.queryByRole("link", { name: /View in Gong/i })).toBeNull()
671
+ })
672
+
673
+ it("keeps absent Gong fields hygienic (no null/undefined/Invalid Date, no empty labels)", () => {
674
+ const { container } = render(
675
+ <TimelineActivity
676
+ events={[
677
+ gongEvent({
678
+ startTime: null,
679
+ durationSeconds: null,
680
+ direction: null,
681
+ outcome: null,
682
+ brief: null,
683
+ keyPoints: null,
684
+ nextSteps: null,
685
+ url: null,
686
+ }),
687
+ ]}
688
+ />,
689
+ )
690
+ const text = container.textContent ?? ""
691
+ expect(text).toContain("Discovery call with Acme")
692
+ expect(text).not.toContain("null")
693
+ expect(text).not.toContain("undefined")
694
+ expect(text).not.toContain("Invalid Date")
695
+ expect(text).not.toContain("NaN")
696
+ expect(text).not.toContain("Key points")
697
+ expect(text).not.toContain("Next steps")
698
+ expect(container.querySelector('[data-slot="timeline-gong-duration"]')).toBeNull()
699
+ })
700
+
701
+ it("collapses to a duration + snippet preview and expands the Gong card", () => {
702
+ const event = minimal({
703
+ isInteractive: true,
704
+ defaultExpanded: false,
705
+ gongCall: {
706
+ title: "Pricing sync",
707
+ durationSeconds: 2280,
708
+ brief: "Walked through the enterprise tier.",
709
+ },
710
+ })
711
+ const { container } = render(<TimelineActivity events={[event]} />)
712
+
713
+ // Collapsed: shows duration + snippet, no expanded body sections.
714
+ expect(container.textContent).toContain("38 min")
715
+ expect(container.textContent).toContain("Walked through the enterprise tier.")
716
+ expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
717
+
718
+ fireEvent.click(screen.getByRole("button", { name: /Expand/i }))
719
+ expect(container.querySelector('[data-slot="timeline-gong-body"]')).not.toBeNull()
720
+ })
721
+
722
+ it.each(["default", "case-panel"] as const)(
723
+ "collapses the expanded Gong %s card from the top affordance",
724
+ (variant) => {
725
+ const { container } = render(<TimelineActivity events={[gongEvent()]} variant={variant} />)
726
+
727
+ const topCollapse = container.querySelector('[data-slot="timeline-collapse-top"]')!
728
+ expect(topCollapse).not.toBeNull()
729
+
730
+ fireEvent.click(topCollapse)
731
+
732
+ expect(container.querySelector('[data-slot="timeline-collapse-top"]')).toBeNull()
733
+ expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
734
+ },
735
+ )
736
+
737
+ it("leaves email-only events unaffected by the Gong call path", () => {
738
+ const event = minimal({
739
+ isInteractive: true,
740
+ defaultExpanded: true,
741
+ email: { from: "Priya Raman", to: "Dana Okafor", subject: "Re: hi", body: "plain body" },
742
+ })
743
+ const { container } = render(<TimelineActivity events={[event]} />)
744
+ expect(container.querySelector('[data-slot="timeline-gong-card"]')).toBeNull()
745
+ expect(container.textContent).toContain("plain body")
746
+ })
747
+
419
748
  })
@@ -99,6 +99,15 @@ export interface ConvMessage {
99
99
  receipt?: { kind: "new" | "read" | "opened" | "sent" | "draft"; label: string }
100
100
  /** HTML body (preferred). Sanitized by the component before rendering. */
101
101
  bodyHtml?: string
102
+ /**
103
+ * Pre-split signature HTML for this message, sanitized server-side by the
104
+ * sender's signature pipeline (which preserves the authored Gmail look —
105
+ * fonts, colors, logos). When present it renders inside the collapsed
106
+ * details section ("•••" toggle) instead of relying on the heuristic footer
107
+ * split of `bodyHtml`, so long signatures never expand the thread by
108
+ * default.
109
+ */
110
+ signatureHtml?: string | null
102
111
  /** Plain-text fallback when `bodyHtml` is absent. */
103
112
  body?: string
104
113
  /** Quoted prior message, collapsed behind a toggle. Sanitized before rendering. */
@@ -470,6 +479,7 @@ function MessageView({
470
479
  <EmailBody
471
480
  html={message.bodyHtml}
472
481
  text={message.body}
482
+ detailsHtml={message.signatureHtml ?? undefined}
473
483
  variant="history"
474
484
  collapseDetails={true}
475
485
  className="text-sm"