@handled-ai/design-system 0.20.3 → 0.20.5

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.
@@ -97,8 +97,16 @@ export interface ConversationThread {
97
97
  cc?: ConvParticipant[]
98
98
  /** Set when this thread's reply halted a playbook (terminal). */
99
99
  paused?: { playbook: string } | null
100
- /** false => operator is not a participant; reply disabled (read-only). */
100
+ /** false => operator cannot reply or create drafts from this thread. */
101
101
  canReply?: boolean
102
+ /** Explains why reply and draft creation are disabled. */
103
+ replyDisabledReason?: string
104
+ /** Existing Gmail draft or thread URL. Rendered as a new-tab link when present. */
105
+ openInGmailUrl?: string | null
106
+ /** Forces the Open in Gmail action into a disabled state. */
107
+ openInGmailDisabled?: boolean
108
+ /** Tooltip/read-only copy for a disabled Open in Gmail action. */
109
+ openInGmailDisabledReason?: string | null
102
110
  messages: ConvMessage[]
103
111
  /** Prefilled reply draft body. */
104
112
  draft?: string
@@ -113,6 +121,17 @@ export interface ConversationReplyPayload {
113
121
  replyAll: boolean
114
122
  }
115
123
 
124
+ /**
125
+ * Result of a server-side reply preview. `htmlBody` is the exact HTML the server
126
+ * prepared for this reply (sanitized by the component before render).
127
+ * `confirmationToken`, when present, identifies the prepared confirmation so a
128
+ * consumer can reuse it for the final send instead of re-preparing the message.
129
+ */
130
+ export interface ConversationReplyPreview {
131
+ htmlBody: string
132
+ confirmationToken?: string
133
+ }
134
+
116
135
  export interface ConversationPanelProps {
117
136
  threads: ConversationThread[]
118
137
  /** Current operator: drives "to me" + the reply avatar. */
@@ -121,6 +140,14 @@ export interface ConversationPanelProps {
121
140
  tenantName?: string
122
141
  onSendReply?: (payload: ConversationReplyPayload) => void | Promise<void>
123
142
  onCreateGmailDraft?: (payload: ConversationReplyPayload) => void | Promise<void>
143
+ /**
144
+ * Server-side preview contract. When provided, the reply preview requests the
145
+ * exact send HTML from the server, the component sanitizes it before render,
146
+ * and retains any returned `confirmationToken` in preview state so a consumer
147
+ * can reuse it for the final send. When omitted, the composer falls back to a
148
+ * clearly labeled local draft preview only.
149
+ */
150
+ onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
124
151
  onOpenInGmail?: (threadId: string) => void
125
152
  /** Inline-open this thread initially (defaults to the first responded one). */
126
153
  defaultOpenThreadId?: string
@@ -486,7 +513,7 @@ function firstName(name: string): string {
486
513
 
487
514
  const STATUS_PILL: Record<ConvStatus, { label: string; cls: string }> = {
488
515
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
489
- draft: { label: "Draft", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
516
+ draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
490
517
  awaiting: { label: "Awaiting", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
491
518
  viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" },
492
519
  }
@@ -502,6 +529,62 @@ function effectiveStatus(t: ConversationThread): ConvStatus {
502
529
  return t.canReply === false ? "viewing" : t.status
503
530
  }
504
531
 
532
+ function disabledOpenInGmailReason(thread: ConversationThread): string {
533
+ return (
534
+ thread.openInGmailDisabledReason?.trim() ||
535
+ thread.replyDisabledReason?.trim() ||
536
+ "Gmail access is not available for this thread."
537
+ )
538
+ }
539
+
540
+ function canOpenInGmail(thread: ConversationThread, onOpenInGmail?: (threadId: string) => void): boolean {
541
+ return thread.openInGmailDisabled !== true && Boolean(thread.openInGmailUrl || onOpenInGmail)
542
+ }
543
+
544
+ function OpenInGmailButton({
545
+ thread,
546
+ onOpenInGmail,
547
+ label = "Open in Gmail",
548
+ }: {
549
+ thread: ConversationThread
550
+ onOpenInGmail?: (threadId: string) => void
551
+ label?: string
552
+ }) {
553
+ const hasConfiguredAction = Boolean(thread.openInGmailUrl || onOpenInGmail || thread.openInGmailDisabled || thread.openInGmailDisabledReason)
554
+ if (!hasConfiguredAction) return null
555
+
556
+ const disabled = !canOpenInGmail(thread, onOpenInGmail)
557
+ const disabledReason = disabled
558
+ ? disabledOpenInGmailReason(thread)
559
+ : undefined
560
+
561
+ if (!disabled && thread.openInGmailUrl) {
562
+ return (
563
+ <Button type="button" variant="ghost" size="sm" asChild>
564
+ <a href={thread.openInGmailUrl} target="_blank" rel="noopener noreferrer">
565
+ <GmailMark size={14} /> {label}
566
+ </a>
567
+ </Button>
568
+ )
569
+ }
570
+
571
+ return (
572
+ <span className="inline-flex" title={disabledReason}>
573
+ <Button
574
+ type="button"
575
+ variant="ghost"
576
+ size="sm"
577
+ disabled={disabled}
578
+ aria-disabled={disabled || undefined}
579
+ aria-label={disabledReason ? `${label}: ${disabledReason}` : label}
580
+ onClick={disabled ? undefined : () => onOpenInGmail?.(thread.threadId)}
581
+ >
582
+ <GmailMark size={14} /> {label}
583
+ </Button>
584
+ </span>
585
+ )
586
+ }
587
+
505
588
  /* ── One message (collapsible) ──────────────────────────────────────────── */
506
589
 
507
590
  function MessageView({
@@ -634,6 +717,19 @@ function MessageView({
634
717
 
635
718
  /* ── Reply composer ─────────────────────────────────────────────────────── */
636
719
 
720
+ type PreviewState = {
721
+ loading: boolean
722
+ /** Sanitized HTML ready to render. */
723
+ html: string | null
724
+ /** Confirmation token returned by the server preview, retained for reuse on send. */
725
+ confirmationToken: string | null
726
+ error: string | null
727
+ /** True when `html` came from the local fallback rather than the server. */
728
+ local: boolean
729
+ }
730
+
731
+ const IDLE_PREVIEW: PreviewState = { loading: false, html: null, confirmationToken: null, error: null, local: false }
732
+
637
733
  function ReplyComposer({
638
734
  thread,
639
735
  me,
@@ -642,6 +738,8 @@ function ReplyComposer({
642
738
  onClose,
643
739
  onSend,
644
740
  onDraft,
741
+ onPreviewReply,
742
+ draftDisabledReason,
645
743
  }: {
646
744
  thread: ConversationThread
647
745
  me?: ConvParticipant
@@ -650,16 +748,51 @@ function ReplyComposer({
650
748
  onClose: () => void
651
749
  onSend: (body: string, includeSignature: boolean) => void | Promise<void>
652
750
  onDraft: (body: string, includeSignature: boolean) => void | Promise<void>
751
+ onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>
752
+ draftDisabledReason?: string | null
653
753
  }) {
654
754
  const [body, setBody] = React.useState(thread.draft ?? "")
655
755
  const [sig, setSig] = React.useState(true)
656
756
  const [preview, setPreview] = React.useState(false)
757
+ const [previewState, setPreviewState] = React.useState<PreviewState>(IDLE_PREVIEW)
657
758
  const [sending, setSending] = React.useState(false)
658
759
  const [sendError, setSendError] = React.useState<string | null>(null)
659
760
  const ccList = replyAll ? thread.cc ?? [] : []
660
761
  const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`
762
+ const draftDisabled = Boolean(draftDisabledReason)
763
+
764
+ const localPreviewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "")
765
+
766
+ const openPreview = async () => {
767
+ setPreview(true)
768
+ setSendError(null)
661
769
 
662
- const previewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "")
770
+ if (!onPreviewReply) {
771
+ // No server preview contract: render a sanitized local draft preview only.
772
+ setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true })
773
+ return
774
+ }
775
+
776
+ setPreviewState({ loading: true, html: null, confirmationToken: null, error: null, local: false })
777
+ try {
778
+ const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll })
779
+ setPreviewState({
780
+ loading: false,
781
+ html: sanitizeHtml(result.htmlBody ?? ""),
782
+ confirmationToken: result.confirmationToken ?? null,
783
+ error: null,
784
+ local: false,
785
+ })
786
+ } catch (error) {
787
+ setPreviewState({
788
+ loading: false,
789
+ html: null,
790
+ confirmationToken: null,
791
+ error: error instanceof Error ? error.message : "Could not load the preview. Please try again.",
792
+ local: false,
793
+ })
794
+ }
795
+ }
663
796
 
664
797
  const handleSend = async () => {
665
798
  setSending(true)
@@ -667,6 +800,7 @@ function ReplyComposer({
667
800
  try {
668
801
  await onSend(body, sig)
669
802
  setPreview(false)
803
+ setPreviewState(IDLE_PREVIEW)
670
804
  } catch (error) {
671
805
  setSendError(error instanceof Error ? error.message : "Could not send this reply. Please try again.")
672
806
  } finally {
@@ -675,11 +809,13 @@ function ReplyComposer({
675
809
  }
676
810
 
677
811
  const handleDraft = async () => {
812
+ if (draftDisabled) return
678
813
  setSending(true)
679
814
  setSendError(null)
680
815
  try {
681
816
  await onDraft(body, sig)
682
817
  setPreview(false)
818
+ setPreviewState(IDLE_PREVIEW)
683
819
  } catch (error) {
684
820
  setSendError(error instanceof Error ? error.message : "Could not create the Gmail draft. Please try again.")
685
821
  } finally {
@@ -742,7 +878,7 @@ function ReplyComposer({
742
878
  onKeyDown={(e) => {
743
879
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
744
880
  e.preventDefault()
745
- setPreview(true)
881
+ void openPreview()
746
882
  }
747
883
  }}
748
884
  />
@@ -760,22 +896,24 @@ function ReplyComposer({
760
896
  <Switch checked={sig} onCheckedChange={setSig} aria-label="Toggle signature" />
761
897
  Signature
762
898
  </label>
763
- <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => setPreview(true)}>
899
+ <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => void openPreview()}>
764
900
  <Eye size={14} /> Preview
765
901
  </Button>
766
- <Button type="button" size="sm" disabled={sending} onClick={() => setPreview(true)}>
902
+ <Button type="button" size="sm" disabled={sending} onClick={() => void openPreview()}>
767
903
  <Send size={14} /> Send
768
904
  </Button>
769
905
  </div>
770
906
 
771
- <Dialog open={preview} onOpenChange={(open) => { if (!sending) setPreview(open) }}>
907
+ <Dialog open={preview} onOpenChange={(open) => { if (!sending) { setPreview(open); if (!open) setPreviewState(IDLE_PREVIEW) } }}>
772
908
  <DialogContent className="max-w-xl">
773
909
  <DialogHeader>
774
910
  <DialogTitle className="flex items-center gap-1.5 text-[15px]">
775
- <Eye size={15} /> Preview: this is exactly what sends
911
+ <Eye size={15} /> {previewState.local ? "Local draft preview" : "Reply preview"}
776
912
  </DialogTitle>
777
913
  <DialogDescription>
778
- Stays on the {subject.replace(/^Re:\s*/i, "")} thread. Gmail keeps it threaded.
914
+ {previewState.local
915
+ ? "Local draft preview only — the server prepares the exact message on send."
916
+ : <>Stays on the {subject.replace(/^Re:\s*/i, "")} thread. Gmail keeps it threaded.</>}
779
917
  </DialogDescription>
780
918
  </DialogHeader>
781
919
  <div className="border-border space-y-1 rounded-md border p-3 text-[13px]">
@@ -792,29 +930,48 @@ function ReplyComposer({
792
930
  {subject}
793
931
  </div>
794
932
  </div>
795
- <div className={cn(PROSE, "max-h-72 overflow-auto")} dangerouslySetInnerHTML={{ __html: previewHtml }} />
933
+ {previewState.loading ? (
934
+ <div data-slot="conv-preview-loading" role="status" className="text-muted-foreground flex items-center gap-2 px-1 py-6 text-[13px]">
935
+ <span className="border-muted-foreground/40 border-t-foreground size-4 animate-spin rounded-full border-2" aria-hidden />
936
+ Loading preview…
937
+ </div>
938
+ ) : previewState.error ? (
939
+ <p role="alert" className="text-destructive text-sm">
940
+ {previewState.error}
941
+ </p>
942
+ ) : (
943
+ <div
944
+ data-slot="conv-preview-body"
945
+ data-confirmation-token={previewState.confirmationToken ?? undefined}
946
+ className={cn(PROSE, "max-h-72 overflow-auto")}
947
+ dangerouslySetInnerHTML={{ __html: previewState.html ?? "" }}
948
+ />
949
+ )}
796
950
  {sendError ? (
797
951
  <p role="alert" className="text-destructive text-sm">
798
952
  {sendError}
799
953
  </p>
800
954
  ) : null}
801
955
  <DialogFooter className="sm:justify-between">
802
- <button
803
- type="button"
804
- disabled={sending}
805
- onClick={handleDraft}
806
- className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
807
- >
808
- <GmailMark size={14} /> Open draft in Gmail
809
- </button>
956
+ <span className="inline-flex" title={draftDisabledReason ?? undefined}>
957
+ <button
958
+ type="button"
959
+ disabled={sending || previewState.loading || draftDisabled}
960
+ onClick={handleDraft}
961
+ aria-label={draftDisabledReason ? `Open draft in Gmail: ${draftDisabledReason}` : "Open draft in Gmail"}
962
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50"
963
+ >
964
+ <GmailMark size={14} /> Open draft in Gmail
965
+ </button>
966
+ </span>
810
967
  <span className="flex items-center gap-2">
811
- <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => setPreview(false)}>
968
+ <Button type="button" variant="outline" size="sm" disabled={sending} onClick={() => { setPreview(false); setPreviewState(IDLE_PREVIEW) }}>
812
969
  Keep editing
813
970
  </Button>
814
971
  <Button
815
972
  type="button"
816
973
  size="sm"
817
- disabled={sending}
974
+ disabled={sending || previewState.loading}
818
975
  onClick={handleSend}
819
976
  >
820
977
  <Send size={14} /> {sending ? "Sending..." : "Send now"}
@@ -841,6 +998,7 @@ function ThreadBody({
841
998
  tenantName,
842
999
  onSendReply,
843
1000
  onCreateGmailDraft,
1001
+ onPreviewReply,
844
1002
  onOpenInGmail,
845
1003
  }: {
846
1004
  thread: ConversationThread
@@ -848,9 +1006,12 @@ function ThreadBody({
848
1006
  tenantName?: string
849
1007
  onSendReply?: (p: ConversationReplyPayload) => void | Promise<void>
850
1008
  onCreateGmailDraft?: (p: ConversationReplyPayload) => void | Promise<void>
1009
+ onPreviewReply?: (p: ConversationReplyPayload) => Promise<ConversationReplyPreview>
851
1010
  onOpenInGmail?: (threadId: string) => void
852
1011
  }) {
853
1012
  const canReply = thread.canReply !== false
1013
+ const replyDisabledReason = thread.replyDisabledReason?.trim() || "You are not a participant on this thread, so replying is disabled here."
1014
+ const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread."
854
1015
  const hasCc = !!(thread.cc && thread.cc.length)
855
1016
  const [mode, setMode] = React.useState<ThreadMode>("idle")
856
1017
  const [replyAll, setReplyAll] = React.useState(false)
@@ -865,7 +1026,7 @@ function ThreadBody({
865
1026
  const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }))
866
1027
 
867
1028
  return (
868
- <div data-slot="conv-thread-body" className="space-y-1.5">
1029
+ <div data-slot="conv-thread-body" className="space-y-2">
869
1030
  {canReply && thread.paused ? (
870
1031
  <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]">
871
1032
  <Pause size={13} className="mt-0.5 shrink-0" />
@@ -883,16 +1044,17 @@ function ThreadBody({
883
1044
  </div>
884
1045
 
885
1046
  {!canReply ? (
886
- <div className="border-border bg-muted/30 text-muted-foreground flex items-start gap-2 rounded-md border p-2.5 text-[12px]">
1047
+ <div className="border-border bg-muted/30 text-muted-foreground flex flex-wrap items-start gap-2 rounded-md border p-2.5 text-[12px]">
887
1048
  <Eye size={14} className="mt-0.5 shrink-0" />
888
- <span>
889
- <b>Viewing only.</b> You’re not a participant on this thread, so replying is disabled here.
1049
+ <span className="min-w-0 flex-1">
1050
+ <b>Viewing only.</b> {replyDisabledReason}
890
1051
  </span>
1052
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
891
1053
  </div>
892
1054
  ) : null}
893
1055
 
894
1056
  {canReply && mode === "idle" ? (
895
- <div className="flex flex-wrap items-center gap-2">
1057
+ <div data-slot="conv-action-row" className="border-border/70 mt-1 flex flex-wrap items-center gap-x-3 gap-y-2 border-t pt-3">
896
1058
  <Button type="button" size="sm" onClick={() => { setReplyAll(false); setMode("replying") }}>
897
1059
  <Reply size={15} /> Reply
898
1060
  </Button>
@@ -901,11 +1063,9 @@ function ThreadBody({
901
1063
  <ReplyAll size={14} /> Reply all
902
1064
  </Button>
903
1065
  ) : null}
904
- <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
905
- <GmailMark size={14} /> Open in Gmail
906
- </Button>
907
- <span className="text-muted-foreground/70 ml-auto inline-flex items-center gap-1 text-[11px]">
908
- <GitMerge size={12} /> Stays on this thread
1066
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
1067
+ <span className="text-muted-foreground/70 ml-auto inline-flex items-center gap-1.5 text-[12px]">
1068
+ <GitMerge size={13} /> Stays on this thread
909
1069
  </span>
910
1070
  </div>
911
1071
  ) : null}
@@ -916,15 +1076,18 @@ function ThreadBody({
916
1076
  me={me}
917
1077
  replyAll={replyAll}
918
1078
  tenantName={tenantName}
1079
+ onPreviewReply={onPreviewReply}
919
1080
  onClose={() => setMode("idle")}
920
1081
  onSend={async (body, includeSignature) => {
921
1082
  await onSendReply?.({ threadId: thread.threadId, body, includeSignature, replyAll })
922
1083
  setMode("sent")
923
1084
  }}
924
1085
  onDraft={async (body, includeSignature) => {
925
- await onCreateGmailDraft?.({ threadId: thread.threadId, body, includeSignature, replyAll })
1086
+ if (!onCreateGmailDraft) return
1087
+ await onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll })
926
1088
  setMode("draft")
927
1089
  }}
1090
+ draftDisabledReason={draftDisabledReason}
928
1091
  />
929
1092
  ) : null}
930
1093
 
@@ -947,9 +1110,7 @@ function ThreadBody({
947
1110
  <span className="flex-1">
948
1111
  <b>Draft saved to Gmail.</b> Waiting on the <b>Re: {thread.subject}</b> thread; open it there to finish. Nothing was sent.
949
1112
  </span>
950
- <Button type="button" variant="ghost" size="sm" onClick={() => onOpenInGmail?.(thread.threadId)}>
951
- <GmailMark size={14} /> Open in Gmail
952
- </Button>
1113
+ <OpenInGmailButton thread={thread} onOpenInGmail={onOpenInGmail} />
953
1114
  <Button type="button" variant="ghost" size="sm" onClick={() => setMode("idle")}>
954
1115
  Done
955
1116
  </Button>
@@ -969,12 +1130,13 @@ function ThreadRow({
969
1130
  tenantName,
970
1131
  onSendReply,
971
1132
  onCreateGmailDraft,
1133
+ onPreviewReply,
972
1134
  onOpenInGmail,
973
1135
  }: {
974
1136
  thread: ConversationThread
975
1137
  open: boolean
976
1138
  onToggleOpen: () => void
977
- } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onOpenInGmail">) {
1139
+ } & Pick<ConversationPanelProps, "me" | "tenantName" | "onSendReply" | "onCreateGmailDraft" | "onPreviewReply" | "onOpenInGmail">) {
978
1140
  const status = effectiveStatus(thread)
979
1141
  const last = thread.messages[thread.messages.length - 1]
980
1142
  const who = last?.direction === "inbound" ? firstName(last.from.name) : "You"
@@ -992,8 +1154,8 @@ function ThreadRow({
992
1154
  <span className={cn("size-2 shrink-0 rounded-full", STATUS_DOT[status])} aria-hidden />
993
1155
  <span className="min-w-0 flex-1">
994
1156
  <span className="flex items-center gap-2">
995
- <span className="truncate text-[13px] font-semibold">{thread.subject}</span>
996
- <span className={cn("shrink-0 rounded border px-1.5 text-[10px] font-medium leading-4", pill.cls)}>
1157
+ <span className="truncate text-sm font-semibold">{thread.subject}</span>
1158
+ <span className={cn("shrink-0 rounded-md border px-1.5 py-px text-[10px] font-medium leading-4", pill.cls)}>
997
1159
  {pill.label}
998
1160
  </span>
999
1161
  </span>
@@ -1017,6 +1179,7 @@ function ThreadRow({
1017
1179
  tenantName={tenantName}
1018
1180
  onSendReply={onSendReply}
1019
1181
  onCreateGmailDraft={onCreateGmailDraft}
1182
+ onPreviewReply={onPreviewReply}
1020
1183
  onOpenInGmail={onOpenInGmail}
1021
1184
  />
1022
1185
  </div>
@@ -1033,6 +1196,7 @@ function ConversationPanel({
1033
1196
  tenantName,
1034
1197
  onSendReply,
1035
1198
  onCreateGmailDraft,
1199
+ onPreviewReply,
1036
1200
  onOpenInGmail,
1037
1201
  defaultOpenThreadId,
1038
1202
  className,
@@ -1100,8 +1264,8 @@ function ConversationPanel({
1100
1264
  {badge.label}
1101
1265
  </span>
1102
1266
  <span className="min-w-0 flex-1">
1103
- <span className="block truncate text-[13px] font-semibold">{headTitle}</span>
1104
- <span className="text-muted-foreground block truncate text-xs">
1267
+ <span className="block truncate text-sm font-semibold leading-tight">{headTitle}</span>
1268
+ <span className="text-muted-foreground mt-0.5 block truncate text-xs">
1105
1269
  {threads.length} {threads.length === 1 ? "thread" : "threads"} on this action
1106
1270
  {anyPaused ? <> · <b>playbook stopped</b></> : null}
1107
1271
  </span>
@@ -1125,6 +1289,7 @@ function ConversationPanel({
1125
1289
  tenantName={tenantName}
1126
1290
  onSendReply={onSendReply}
1127
1291
  onCreateGmailDraft={onCreateGmailDraft}
1292
+ onPreviewReply={onPreviewReply}
1128
1293
  onOpenInGmail={onOpenInGmail}
1129
1294
  />
1130
1295
  ))}
@@ -58,7 +58,7 @@ export interface OwnerPerson {
58
58
 
59
59
  /* ── shared bits ─────────────────────────────────────────────────────────── */
60
60
 
61
- function SalesforceMark({ size = 14 }: { size?: number }) {
61
+ function SalesforceMark({ size = 13 }: { size?: number }) {
62
62
  return (
63
63
  // eslint-disable-next-line @next/next/no-img-element
64
64
  <img
@@ -82,8 +82,10 @@ function OwnerAvatar({ person, size = "sm" }: { person: OwnerPerson; size?: "sm"
82
82
  )
83
83
  }
84
84
 
85
+ // Component-owned compact sizing. Set directly here (not via consumer
86
+ // className) because cn()/tailwind-merge can strip conflicting overrides.
85
87
  const chipBase =
86
- "inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] " +
88
+ "inline-flex h-7 items-center gap-1 rounded-md border border-border bg-background px-2 text-[12px] " +
87
89
  "shadow-[0_1px_1px_rgba(0,0,0,0.03)]"
88
90
 
89
91
  /* ── Signal owner (Handled assignment, editable) ─────────────────────────── */
@@ -115,14 +117,14 @@ function SignalOwnerChip({
115
117
  {owner ? (
116
118
  <OwnerAvatar person={owner} />
117
119
  ) : (
118
- <span className="text-muted-foreground inline-flex size-[18px] items-center justify-center">
119
- <UserPlus size={13} />
120
+ <span className="text-muted-foreground inline-flex size-4 items-center justify-center">
121
+ <UserPlus size={12} />
120
122
  </span>
121
123
  )}
122
- <span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
124
+ <span className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase">
123
125
  Signal owner
124
126
  </span>
125
- <span className="bg-border/70 mx-0.5 h-3.5 w-px" aria-hidden />
127
+ <span className="bg-border/70 mx-0.5 h-3 w-px" aria-hidden />
126
128
  <span className={cn("font-medium", owner ? "text-foreground" : "text-muted-foreground")}>
127
129
  {owner ? owner.name : "Unassigned"}
128
130
  </span>
@@ -155,7 +157,7 @@ function SignalOwnerChip({
155
157
  >
156
158
  {value}
157
159
  <span className="text-muted-foreground ml-0.5">
158
- {open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
160
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
159
161
  </span>
160
162
  </button>
161
163
  </DropdownMenuTrigger>
@@ -289,7 +291,7 @@ function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
289
291
  <OwnerAvatar person={only} />
290
292
  <span className="text-foreground font-medium">{only.name}</span>
291
293
  <span className="text-muted-foreground ml-0.5">
292
- {open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
294
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
293
295
  </span>
294
296
  </button>
295
297
  </DropdownMenuTrigger>
@@ -327,11 +329,11 @@ function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
327
329
  ))}
328
330
  </span>
329
331
  <span className="text-foreground font-medium">Account owners</span>
330
- <span className="bg-muted text-muted-foreground rounded px-1 text-[11px] font-semibold tabular-nums">
332
+ <span className="bg-muted text-muted-foreground rounded px-1 text-[10px] font-semibold tabular-nums">
331
333
  ×{owners.length}
332
334
  </span>
333
335
  <span className="text-muted-foreground ml-0.5">
334
- {open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
336
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
335
337
  </span>
336
338
  </button>
337
339
  </DropdownMenuTrigger>
@@ -163,21 +163,21 @@ const TIMELINE_VARIANT_CLASSES: Record<TimelineActivityVariant, TimelineVariantC
163
163
  },
164
164
  "case-panel": {
165
165
  outerRowGap: "group relative flex gap-3",
166
- connector: "absolute left-[7px] top-[18px] bottom-[-4px] w-px bg-border/60",
167
- dotWrapperSize: "relative z-10 mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background",
168
- dot: "flex h-3.5 w-3.5 items-center justify-center rounded-full border ring-[3px] ring-background",
169
- contentPadding: "flex-1 pb-4 pt-0.5",
170
- titleRowSpacing: "flex min-w-0 flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between",
171
- title: "pr-3 text-[13px] leading-snug text-foreground",
172
- time: "mt-0.5 shrink-0 whitespace-nowrap text-[11px] leading-snug text-muted-foreground/70",
173
- cardContainer: "overflow-hidden rounded-lg border border-border/70 bg-card shadow-sm",
174
- cardHeader: "border-b border-border/60 bg-background px-3 py-2",
175
- cardBody: "px-3 py-2.5 text-sm",
176
- cardFooter: "border-t border-border/60 bg-background/50 px-3 py-1.5",
177
- collapsedPreview: "flex items-center justify-between gap-2 px-3 py-2 text-sm text-muted-foreground",
166
+ connector: "absolute left-[11px] top-6 bottom-[-2px] w-px bg-border/50",
167
+ dotWrapperSize: "relative z-10 mt-0.5 flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-background",
168
+ dot: "flex h-[22px] w-[22px] items-center justify-center rounded-full border ring-4 ring-background [&>svg]:h-3 [&>svg]:w-3",
169
+ contentPadding: "flex-1 min-w-0 pb-5 pt-px",
170
+ titleRowSpacing: "flex min-w-0 items-start justify-between gap-3",
171
+ title: "min-w-0 pr-1 text-[13.5px] font-medium leading-tight text-foreground",
172
+ time: "shrink-0 whitespace-nowrap pt-px text-[11px] leading-tight text-muted-foreground/60",
173
+ cardContainer: "overflow-hidden rounded-lg border border-border/70 bg-card",
174
+ 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",
175
+ cardBody: "px-3 py-2.5 text-[13px] leading-relaxed",
176
+ cardFooter: "border-t border-border/60 bg-muted/10 px-3 py-1.5",
177
+ collapsedPreview: "flex items-center justify-between gap-2 px-3 py-2 text-[13px] text-muted-foreground",
178
178
  actionLinkRow: "flex items-center justify-end gap-2 px-3 py-1.5",
179
- actionLink: "inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/70 transition-colors hover:text-foreground",
180
- nonInteractiveContent: "pr-2 text-[13px] leading-snug text-muted-foreground",
179
+ actionLink: "inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground/70 transition-colors hover:text-foreground",
180
+ nonInteractiveContent: "pr-2 text-[13px] leading-relaxed text-muted-foreground",
181
181
  },
182
182
  }
183
183