@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.
- package/dist/components/comment-composer.js +4 -4
- package/dist/components/comment-composer.js.map +1 -1
- package/dist/components/conversation-panel.d.ts +29 -3
- package/dist/components/conversation-panel.js +142 -39
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/owner-chips.js +9 -9
- package/dist/components/owner-chips.js.map +1 -1
- package/dist/components/timeline-activity.js +14 -14
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/comment-composer.test.tsx +28 -0
- package/src/components/__tests__/conversation-panel.test.tsx +217 -0
- package/src/components/__tests__/owner-chips.test.tsx +27 -0
- package/src/components/__tests__/timeline-activity.test.tsx +33 -0
- package/src/components/comment-composer.tsx +4 -4
- package/src/components/conversation-panel.tsx +204 -39
- package/src/components/owner-chips.tsx +12 -10
- package/src/components/timeline-activity.tsx +14 -14
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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={() =>
|
|
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} />
|
|
911
|
+
<Eye size={15} /> {previewState.local ? "Local draft preview" : "Reply preview"}
|
|
776
912
|
</DialogTitle>
|
|
777
913
|
<DialogDescription>
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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-
|
|
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>
|
|
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
|
-
<
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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-
|
|
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-
|
|
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 =
|
|
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-
|
|
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-
|
|
119
|
-
<UserPlus size={
|
|
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-[
|
|
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
|
|
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={
|
|
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={
|
|
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-[
|
|
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={
|
|
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-[
|
|
167
|
-
dotWrapperSize: "relative z-10 mt-
|
|
168
|
-
dot: "flex h-
|
|
169
|
-
contentPadding: "flex-1 pb-
|
|
170
|
-
titleRowSpacing: "flex min-w-0
|
|
171
|
-
title: "pr-
|
|
172
|
-
time: "
|
|
173
|
-
cardContainer: "overflow-hidden rounded-lg border border-border/70 bg-card
|
|
174
|
-
cardHeader: "border-b border-border/60 bg-
|
|
175
|
-
cardBody: "px-3 py-2.5 text-
|
|
176
|
-
cardFooter: "border-t border-border/60 bg-
|
|
177
|
-
collapsedPreview: "flex items-center justify-between gap-2 px-3 py-2 text-
|
|
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
|
|
180
|
-
nonInteractiveContent: "pr-2 text-[13px] leading-
|
|
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
|
|