@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.
@@ -34,12 +34,12 @@ function CommentComposer({
34
34
  "data-slot": "comment-composer",
35
35
  "data-open": open ? "true" : void 0,
36
36
  className: cn(
37
- "border-border bg-background flex items-start gap-2 rounded-lg border p-2 transition-colors",
37
+ "border-border bg-background flex items-start gap-2 rounded-lg border px-2 py-1.5 transition-colors",
38
38
  open && "ring-ring/30 ring-2",
39
39
  className
40
40
  ),
41
41
  children: [
42
- /* @__PURE__ */ jsxs(Avatar, { size: "sm", className: "mt-0.5", children: [
42
+ /* @__PURE__ */ jsxs(Avatar, { size: "sm", className: "mt-px", children: [
43
43
  (author == null ? void 0 : author.avatarUrl) ? /* @__PURE__ */ jsx(AvatarImage, { src: author.avatarUrl, alt: (_a = author.name) != null ? _a : "You" }) : null,
44
44
  /* @__PURE__ */ jsx(AvatarFallback, { className: "bg-muted text-muted-foreground text-[10px] font-medium uppercase", children: getInitials({ name: author == null ? void 0 : author.name, email: author == null ? void 0 : author.email }) })
45
45
  ] }),
@@ -54,7 +54,7 @@ function CommentComposer({
54
54
  placeholder,
55
55
  rows: open ? 3 : 1,
56
56
  className: cn(
57
- "resize-none border-0 bg-transparent p-1 text-sm shadow-none focus-visible:ring-0",
57
+ "resize-none border-0 bg-transparent px-1 py-0.5 text-sm leading-snug shadow-none focus-visible:ring-0",
58
58
  !open && "min-h-0"
59
59
  ),
60
60
  onKeyDown: (e) => {
@@ -65,7 +65,7 @@ function CommentComposer({
65
65
  }
66
66
  }
67
67
  ),
68
- open ? /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
68
+ open ? /* @__PURE__ */ jsxs("div", { className: "mt-0.5 flex items-center justify-between gap-2", children: [
69
69
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground inline-flex items-center gap-1 text-[11px]", children: [
70
70
  /* @__PURE__ */ jsx(Lock, { size: 12 }),
71
71
  " ",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/comment-composer.tsx"],"sourcesContent":["\"use client\"\n\n/**\n * comment-composer.tsx — an internal-note composer for the case activity\n * timeline. Posting a comment prepends an `operatorNote` event to the log\n * (wired by the consumer). Collapses to a single line; expands on focus or\n * when it has text. ⌘↵ / Ctrl↵ posts.\n *\n * Presentational: `onPost` does the work (the consumer persists the note and\n * adds it to the timeline). Reuses Avatar / Button / Textarea primitives.\n */\n\nimport * as React from \"react\"\nimport { Lock } from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport { getInitials } from \"../lib/user-display\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"./avatar\"\nimport { Button } from \"./button\"\nimport { Textarea } from \"./textarea\"\n\nexport interface CommentComposerProps {\n /** Called with the trimmed note text when the operator posts. */\n onPost: (text: string) => void\n /** Current operator (for the avatar). */\n author?: { name?: string; email?: string; avatarUrl?: string | null }\n placeholder?: string\n /** Hint shown in the footer; defaults to the internal-note reassurance. */\n hint?: string\n className?: string\n}\n\nfunction CommentComposer({\n onPost,\n author,\n placeholder = \"Add a comment or internal note…\",\n hint = \"Internal note: only your team sees this\",\n className,\n}: CommentComposerProps) {\n const [text, setText] = React.useState(\"\")\n const [focused, setFocused] = React.useState(false)\n const open = focused || text.length > 0\n const canPost = text.trim().length > 0\n\n const post = () => {\n const value = text.trim()\n if (!value) return\n onPost(value)\n setText(\"\")\n setFocused(false)\n }\n\n return (\n <div\n data-slot=\"comment-composer\"\n data-open={open ? \"true\" : undefined}\n className={cn(\n \"border-border bg-background flex items-start gap-2 rounded-lg border p-2 transition-colors\",\n open && \"ring-ring/30 ring-2\",\n className\n )}\n >\n <Avatar size=\"sm\" className=\"mt-0.5\">\n {author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? \"You\"} /> : null}\n <AvatarFallback className=\"bg-muted text-muted-foreground text-[10px] font-medium uppercase\">\n {getInitials({ name: author?.name, email: author?.email })}\n </AvatarFallback>\n </Avatar>\n\n <div className=\"min-w-0 flex-1\">\n <Textarea\n data-slot=\"comment-composer-input\"\n value={text}\n onChange={(e) => setText(e.target.value)}\n onFocus={() => setFocused(true)}\n placeholder={placeholder}\n rows={open ? 3 : 1}\n className={cn(\n \"resize-none border-0 bg-transparent p-1 text-sm shadow-none focus-visible:ring-0\",\n !open && \"min-h-0\"\n )}\n onKeyDown={(e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === \"Enter\") {\n e.preventDefault()\n post()\n }\n }}\n />\n\n {open ? (\n <div className=\"mt-1 flex items-center justify-between gap-2\">\n <span className=\"text-muted-foreground inline-flex items-center gap-1 text-[11px]\">\n <Lock size={12} /> {hint}\n </span>\n <span className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => {\n setText(\"\")\n setFocused(false)\n }}\n >\n Cancel\n </Button>\n <Button type=\"button\" size=\"sm\" disabled={!canPost} onClick={post}>\n Comment\n <kbd className=\"bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]\">⌘↵</kbd>\n </Button>\n </span>\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n\nexport { CommentComposer }\n"],"mappings":";AA8DM,SACuB,KADvB;AAlDN,YAAY,WAAW;AACvB,SAAS,YAAY;AAErB,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,QAAQ,gBAAgB,mBAAmB;AACpD,SAAS,cAAc;AACvB,SAAS,gBAAgB;AAazB,SAAS,gBAAgB;AAAA,EACvB;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,OAAO;AAAA,EACP;AACF,GAAyB;AAtCzB;AAuCE,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,EAAE;AACzC,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,OAAO,WAAW,KAAK,SAAS;AACtC,QAAM,UAAU,KAAK,KAAK,EAAE,SAAS;AAErC,QAAM,OAAO,MAAM;AACjB,UAAM,QAAQ,KAAK,KAAK;AACxB,QAAI,CAAC,MAAO;AACZ,WAAO,KAAK;AACZ,YAAQ,EAAE;AACV,eAAW,KAAK;AAAA,EAClB;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,aAAW,OAAO,SAAS;AAAA,MAC3B,WAAW;AAAA,QACT;AAAA,QACA,QAAQ;AAAA,QACR;AAAA,MACF;AAAA,MAEA;AAAA,6BAAC,UAAO,MAAK,MAAK,WAAU,UACzB;AAAA,4CAAQ,aAAY,oBAAC,eAAY,KAAK,OAAO,WAAW,MAAK,YAAO,SAAP,YAAe,OAAO,IAAK;AAAA,UACzF,oBAAC,kBAAe,WAAU,oEACvB,sBAAY,EAAE,MAAM,iCAAQ,MAAM,OAAO,iCAAQ,MAAM,CAAC,GAC3D;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,kBACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,aAAU;AAAA,cACV,OAAO;AAAA,cACP,UAAU,CAAC,MAAM,QAAQ,EAAE,OAAO,KAAK;AAAA,cACvC,SAAS,MAAM,WAAW,IAAI;AAAA,cAC9B;AAAA,cACA,MAAM,OAAO,IAAI;AAAA,cACjB,WAAW;AAAA,gBACT;AAAA,gBACA,CAAC,QAAQ;AAAA,cACX;AAAA,cACA,WAAW,CAAC,MAAM;AAChB,qBAAK,EAAE,WAAW,EAAE,YAAY,EAAE,QAAQ,SAAS;AACjD,oBAAE,eAAe;AACjB,uBAAK;AAAA,gBACP;AAAA,cACF;AAAA;AAAA,UACF;AAAA,UAEC,OACC,qBAAC,SAAI,WAAU,gDACb;AAAA,iCAAC,UAAK,WAAU,oEACd;AAAA,kCAAC,QAAK,MAAM,IAAI;AAAA,cAAE;AAAA,cAAE;AAAA,eACtB;AAAA,YACA,qBAAC,UAAK,WAAU,2BACd;AAAA;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAQ;AAAA,kBACR,MAAK;AAAA,kBACL,SAAS,MAAM;AACb,4BAAQ,EAAE;AACV,+BAAW,KAAK;AAAA,kBAClB;AAAA,kBACD;AAAA;AAAA,cAED;AAAA,cACA,qBAAC,UAAO,MAAK,UAAS,MAAK,MAAK,UAAU,CAAC,SAAS,SAAS,MAAM;AAAA;AAAA,gBAEjE,oBAAC,SAAI,WAAU,0DAAyD,0BAAE;AAAA,iBAC5E;AAAA,eACF;AAAA,aACF,IACE;AAAA,WACN;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/comment-composer.tsx"],"sourcesContent":["\"use client\"\n\n/**\n * comment-composer.tsx — an internal-note composer for the case activity\n * timeline. Posting a comment prepends an `operatorNote` event to the log\n * (wired by the consumer). Collapses to a single line; expands on focus or\n * when it has text. ⌘↵ / Ctrl↵ posts.\n *\n * Presentational: `onPost` does the work (the consumer persists the note and\n * adds it to the timeline). Reuses Avatar / Button / Textarea primitives.\n */\n\nimport * as React from \"react\"\nimport { Lock } from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport { getInitials } from \"../lib/user-display\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"./avatar\"\nimport { Button } from \"./button\"\nimport { Textarea } from \"./textarea\"\n\nexport interface CommentComposerProps {\n /** Called with the trimmed note text when the operator posts. */\n onPost: (text: string) => void\n /** Current operator (for the avatar). */\n author?: { name?: string; email?: string; avatarUrl?: string | null }\n placeholder?: string\n /** Hint shown in the footer; defaults to the internal-note reassurance. */\n hint?: string\n className?: string\n}\n\nfunction CommentComposer({\n onPost,\n author,\n placeholder = \"Add a comment or internal note…\",\n hint = \"Internal note: only your team sees this\",\n className,\n}: CommentComposerProps) {\n const [text, setText] = React.useState(\"\")\n const [focused, setFocused] = React.useState(false)\n const open = focused || text.length > 0\n const canPost = text.trim().length > 0\n\n const post = () => {\n const value = text.trim()\n if (!value) return\n onPost(value)\n setText(\"\")\n setFocused(false)\n }\n\n return (\n <div\n data-slot=\"comment-composer\"\n data-open={open ? \"true\" : undefined}\n className={cn(\n \"border-border bg-background flex items-start gap-2 rounded-lg border px-2 py-1.5 transition-colors\",\n open && \"ring-ring/30 ring-2\",\n className\n )}\n >\n <Avatar size=\"sm\" className=\"mt-px\">\n {author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? \"You\"} /> : null}\n <AvatarFallback className=\"bg-muted text-muted-foreground text-[10px] font-medium uppercase\">\n {getInitials({ name: author?.name, email: author?.email })}\n </AvatarFallback>\n </Avatar>\n\n <div className=\"min-w-0 flex-1\">\n <Textarea\n data-slot=\"comment-composer-input\"\n value={text}\n onChange={(e) => setText(e.target.value)}\n onFocus={() => setFocused(true)}\n placeholder={placeholder}\n rows={open ? 3 : 1}\n className={cn(\n \"resize-none border-0 bg-transparent px-1 py-0.5 text-sm leading-snug shadow-none focus-visible:ring-0\",\n !open && \"min-h-0\"\n )}\n onKeyDown={(e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === \"Enter\") {\n e.preventDefault()\n post()\n }\n }}\n />\n\n {open ? (\n <div className=\"mt-0.5 flex items-center justify-between gap-2\">\n <span className=\"text-muted-foreground inline-flex items-center gap-1 text-[11px]\">\n <Lock size={12} /> {hint}\n </span>\n <span className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => {\n setText(\"\")\n setFocused(false)\n }}\n >\n Cancel\n </Button>\n <Button type=\"button\" size=\"sm\" disabled={!canPost} onClick={post}>\n Comment\n <kbd className=\"bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]\">⌘↵</kbd>\n </Button>\n </span>\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n\nexport { CommentComposer }\n"],"mappings":";AA8DM,SACuB,KADvB;AAlDN,YAAY,WAAW;AACvB,SAAS,YAAY;AAErB,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,QAAQ,gBAAgB,mBAAmB;AACpD,SAAS,cAAc;AACvB,SAAS,gBAAgB;AAazB,SAAS,gBAAgB;AAAA,EACvB;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,OAAO;AAAA,EACP;AACF,GAAyB;AAtCzB;AAuCE,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,EAAE;AACzC,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,OAAO,WAAW,KAAK,SAAS;AACtC,QAAM,UAAU,KAAK,KAAK,EAAE,SAAS;AAErC,QAAM,OAAO,MAAM;AACjB,UAAM,QAAQ,KAAK,KAAK;AACxB,QAAI,CAAC,MAAO;AACZ,WAAO,KAAK;AACZ,YAAQ,EAAE;AACV,eAAW,KAAK;AAAA,EAClB;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,aAAW,OAAO,SAAS;AAAA,MAC3B,WAAW;AAAA,QACT;AAAA,QACA,QAAQ;AAAA,QACR;AAAA,MACF;AAAA,MAEA;AAAA,6BAAC,UAAO,MAAK,MAAK,WAAU,SACzB;AAAA,4CAAQ,aAAY,oBAAC,eAAY,KAAK,OAAO,WAAW,MAAK,YAAO,SAAP,YAAe,OAAO,IAAK;AAAA,UACzF,oBAAC,kBAAe,WAAU,oEACvB,sBAAY,EAAE,MAAM,iCAAQ,MAAM,OAAO,iCAAQ,MAAM,CAAC,GAC3D;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,kBACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,aAAU;AAAA,cACV,OAAO;AAAA,cACP,UAAU,CAAC,MAAM,QAAQ,EAAE,OAAO,KAAK;AAAA,cACvC,SAAS,MAAM,WAAW,IAAI;AAAA,cAC9B;AAAA,cACA,MAAM,OAAO,IAAI;AAAA,cACjB,WAAW;AAAA,gBACT;AAAA,gBACA,CAAC,QAAQ;AAAA,cACX;AAAA,cACA,WAAW,CAAC,MAAM;AAChB,qBAAK,EAAE,WAAW,EAAE,YAAY,EAAE,QAAQ,SAAS;AACjD,oBAAE,eAAe;AACjB,uBAAK;AAAA,gBACP;AAAA,cACF;AAAA;AAAA,UACF;AAAA,UAEC,OACC,qBAAC,SAAI,WAAU,kDACb;AAAA,iCAAC,UAAK,WAAU,oEACd;AAAA,kCAAC,QAAK,MAAM,IAAI;AAAA,cAAE;AAAA,cAAE;AAAA,eACtB;AAAA,YACA,qBAAC,UAAK,WAAU,2BACd;AAAA;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAQ;AAAA,kBACR,MAAK;AAAA,kBACL,SAAS,MAAM;AACb,4BAAQ,EAAE;AACV,+BAAW,KAAK;AAAA,kBAClB;AAAA,kBACD;AAAA;AAAA,cAED;AAAA,cACA,qBAAC,UAAO,MAAK,UAAS,MAAK,MAAK,UAAU,CAAC,SAAS,SAAS,MAAM;AAAA;AAAA,gBAEjE,oBAAC,SAAI,WAAU,0DAAyD,0BAAE;AAAA,iBAC5E;AAAA,eACF;AAAA,aACF,IACE;AAAA,WACN;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -63,8 +63,16 @@ interface ConversationThread {
63
63
  paused?: {
64
64
  playbook: string;
65
65
  } | null;
66
- /** false => operator is not a participant; reply disabled (read-only). */
66
+ /** false => operator cannot reply or create drafts from this thread. */
67
67
  canReply?: boolean;
68
+ /** Explains why reply and draft creation are disabled. */
69
+ replyDisabledReason?: string;
70
+ /** Existing Gmail draft or thread URL. Rendered as a new-tab link when present. */
71
+ openInGmailUrl?: string | null;
72
+ /** Forces the Open in Gmail action into a disabled state. */
73
+ openInGmailDisabled?: boolean;
74
+ /** Tooltip/read-only copy for a disabled Open in Gmail action. */
75
+ openInGmailDisabledReason?: string | null;
68
76
  messages: ConvMessage[];
69
77
  /** Prefilled reply draft body. */
70
78
  draft?: string;
@@ -77,6 +85,16 @@ interface ConversationReplyPayload {
77
85
  includeSignature: boolean;
78
86
  replyAll: boolean;
79
87
  }
88
+ /**
89
+ * Result of a server-side reply preview. `htmlBody` is the exact HTML the server
90
+ * prepared for this reply (sanitized by the component before render).
91
+ * `confirmationToken`, when present, identifies the prepared confirmation so a
92
+ * consumer can reuse it for the final send instead of re-preparing the message.
93
+ */
94
+ interface ConversationReplyPreview {
95
+ htmlBody: string;
96
+ confirmationToken?: string;
97
+ }
80
98
  interface ConversationPanelProps {
81
99
  threads: ConversationThread[];
82
100
  /** Current operator: drives "to me" + the reply avatar. */
@@ -85,11 +103,19 @@ interface ConversationPanelProps {
85
103
  tenantName?: string;
86
104
  onSendReply?: (payload: ConversationReplyPayload) => void | Promise<void>;
87
105
  onCreateGmailDraft?: (payload: ConversationReplyPayload) => void | Promise<void>;
106
+ /**
107
+ * Server-side preview contract. When provided, the reply preview requests the
108
+ * exact send HTML from the server, the component sanitizes it before render,
109
+ * and retains any returned `confirmationToken` in preview state so a consumer
110
+ * can reuse it for the final send. When omitted, the composer falls back to a
111
+ * clearly labeled local draft preview only.
112
+ */
113
+ onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>;
88
114
  onOpenInGmail?: (threadId: string) => void;
89
115
  /** Inline-open this thread initially (defaults to the first responded one). */
90
116
  defaultOpenThreadId?: string;
91
117
  className?: string;
92
118
  }
93
- declare function ConversationPanel({ threads, me, tenantName, onSendReply, onCreateGmailDraft, onOpenInGmail, defaultOpenThreadId, className, }: ConversationPanelProps): React.JSX.Element | null;
119
+ declare function ConversationPanel({ threads, me, tenantName, onSendReply, onCreateGmailDraft, onPreviewReply, onOpenInGmail, defaultOpenThreadId, className, }: ConversationPanelProps): React.JSX.Element | null;
94
120
 
95
- export { type ConvMessage, type ConvParticipant, type ConvStatus, ConversationPanel, type ConversationPanelProps, type ConversationReplyPayload, type ConversationThread };
121
+ export { type ConvMessage, type ConvParticipant, type ConvStatus, ConversationPanel, type ConversationPanelProps, type ConversationReplyPayload, type ConversationReplyPreview, type ConversationThread };
@@ -337,7 +337,7 @@ function firstName(name) {
337
337
  }
338
338
  const STATUS_PILL = {
339
339
  responded: { label: "New reply", cls: "bg-status-active-bg text-status-active-fg border-status-active-border" },
340
- draft: { label: "Draft", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
340
+ draft: { label: "Draft", cls: "bg-background text-foreground/80 border-border" },
341
341
  awaiting: { label: "Awaiting", cls: "bg-status-pending-bg text-status-pending-fg border-status-pending-border" },
342
342
  viewing: { label: "Viewing", cls: "bg-muted text-muted-foreground border-border" }
343
343
  };
@@ -350,6 +350,47 @@ const STATUS_DOT = {
350
350
  function effectiveStatus(t) {
351
351
  return t.canReply === false ? "viewing" : t.status;
352
352
  }
353
+ function disabledOpenInGmailReason(thread) {
354
+ var _a, _b;
355
+ return ((_a = thread.openInGmailDisabledReason) == null ? void 0 : _a.trim()) || ((_b = thread.replyDisabledReason) == null ? void 0 : _b.trim()) || "Gmail access is not available for this thread.";
356
+ }
357
+ function canOpenInGmail(thread, onOpenInGmail) {
358
+ return thread.openInGmailDisabled !== true && Boolean(thread.openInGmailUrl || onOpenInGmail);
359
+ }
360
+ function OpenInGmailButton({
361
+ thread,
362
+ onOpenInGmail,
363
+ label = "Open in Gmail"
364
+ }) {
365
+ const hasConfiguredAction = Boolean(thread.openInGmailUrl || onOpenInGmail || thread.openInGmailDisabled || thread.openInGmailDisabledReason);
366
+ if (!hasConfiguredAction) return null;
367
+ const disabled = !canOpenInGmail(thread, onOpenInGmail);
368
+ const disabledReason = disabled ? disabledOpenInGmailReason(thread) : void 0;
369
+ if (!disabled && thread.openInGmailUrl) {
370
+ return /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", asChild: true, children: /* @__PURE__ */ jsxs("a", { href: thread.openInGmailUrl, target: "_blank", rel: "noopener noreferrer", children: [
371
+ /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
372
+ " ",
373
+ label
374
+ ] }) });
375
+ }
376
+ return /* @__PURE__ */ jsx("span", { className: "inline-flex", title: disabledReason, children: /* @__PURE__ */ jsxs(
377
+ Button,
378
+ {
379
+ type: "button",
380
+ variant: "ghost",
381
+ size: "sm",
382
+ disabled,
383
+ "aria-disabled": disabled || void 0,
384
+ "aria-label": disabledReason ? `${label}: ${disabledReason}` : label,
385
+ onClick: disabled ? void 0 : () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId),
386
+ children: [
387
+ /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
388
+ " ",
389
+ label
390
+ ]
391
+ }
392
+ ) });
393
+ }
353
394
  function MessageView({
354
395
  message,
355
396
  expanded,
@@ -453,6 +494,7 @@ function MessageView({
453
494
  ] })
454
495
  ] });
455
496
  }
497
+ const IDLE_PREVIEW = { loading: false, html: null, confirmationToken: null, error: null, local: false };
456
498
  function ReplyComposer({
457
499
  thread,
458
500
  me,
@@ -460,23 +502,56 @@ function ReplyComposer({
460
502
  tenantName,
461
503
  onClose,
462
504
  onSend,
463
- onDraft
505
+ onDraft,
506
+ onPreviewReply,
507
+ draftDisabledReason
464
508
  }) {
465
- var _a, _b;
509
+ var _a, _b, _c, _d;
466
510
  const [body, setBody] = React.useState((_a = thread.draft) != null ? _a : "");
467
511
  const [sig, setSig] = React.useState(true);
468
512
  const [preview, setPreview] = React.useState(false);
513
+ const [previewState, setPreviewState] = React.useState(IDLE_PREVIEW);
469
514
  const [sending, setSending] = React.useState(false);
470
515
  const [sendError, setSendError] = React.useState(null);
471
516
  const ccList = replyAll ? (_b = thread.cc) != null ? _b : [] : [];
472
517
  const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`;
473
- const previewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "");
518
+ const draftDisabled = Boolean(draftDisabledReason);
519
+ const localPreviewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "");
520
+ const openPreview = async () => {
521
+ var _a2, _b2;
522
+ setPreview(true);
523
+ setSendError(null);
524
+ if (!onPreviewReply) {
525
+ setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true });
526
+ return;
527
+ }
528
+ setPreviewState({ loading: true, html: null, confirmationToken: null, error: null, local: false });
529
+ try {
530
+ const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll });
531
+ setPreviewState({
532
+ loading: false,
533
+ html: sanitizeHtml((_a2 = result.htmlBody) != null ? _a2 : ""),
534
+ confirmationToken: (_b2 = result.confirmationToken) != null ? _b2 : null,
535
+ error: null,
536
+ local: false
537
+ });
538
+ } catch (error) {
539
+ setPreviewState({
540
+ loading: false,
541
+ html: null,
542
+ confirmationToken: null,
543
+ error: error instanceof Error ? error.message : "Could not load the preview. Please try again.",
544
+ local: false
545
+ });
546
+ }
547
+ };
474
548
  const handleSend = async () => {
475
549
  setSending(true);
476
550
  setSendError(null);
477
551
  try {
478
552
  await onSend(body, sig);
479
553
  setPreview(false);
554
+ setPreviewState(IDLE_PREVIEW);
480
555
  } catch (error) {
481
556
  setSendError(error instanceof Error ? error.message : "Could not send this reply. Please try again.");
482
557
  } finally {
@@ -484,11 +559,13 @@ function ReplyComposer({
484
559
  }
485
560
  };
486
561
  const handleDraft = async () => {
562
+ if (draftDisabled) return;
487
563
  setSending(true);
488
564
  setSendError(null);
489
565
  try {
490
566
  await onDraft(body, sig);
491
567
  setPreview(false);
568
+ setPreviewState(IDLE_PREVIEW);
492
569
  } catch (error) {
493
570
  setSendError(error instanceof Error ? error.message : "Could not create the Gmail draft. Please try again.");
494
571
  } finally {
@@ -545,7 +622,7 @@ function ReplyComposer({
545
622
  onKeyDown: (e) => {
546
623
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
547
624
  e.preventDefault();
548
- setPreview(true);
625
+ void openPreview();
549
626
  }
550
627
  }
551
628
  }
@@ -560,28 +637,32 @@ function ReplyComposer({
560
637
  /* @__PURE__ */ jsx(Switch, { checked: sig, onCheckedChange: setSig, "aria-label": "Toggle signature" }),
561
638
  "Signature"
562
639
  ] }),
563
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => setPreview(true), children: [
640
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => void openPreview(), children: [
564
641
  /* @__PURE__ */ jsx(Eye, { size: 14 }),
565
642
  " Preview"
566
643
  ] }),
567
- /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", disabled: sending, onClick: () => setPreview(true), children: [
644
+ /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", disabled: sending, onClick: () => void openPreview(), children: [
568
645
  /* @__PURE__ */ jsx(Send, { size: 14 }),
569
646
  " Send"
570
647
  ] })
571
648
  ] }),
572
649
  /* @__PURE__ */ jsx(Dialog, { open: preview, onOpenChange: (open) => {
573
- if (!sending) setPreview(open);
650
+ if (!sending) {
651
+ setPreview(open);
652
+ if (!open) setPreviewState(IDLE_PREVIEW);
653
+ }
574
654
  }, children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-w-xl", children: [
575
655
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
576
656
  /* @__PURE__ */ jsxs(DialogTitle, { className: "flex items-center gap-1.5 text-[15px]", children: [
577
657
  /* @__PURE__ */ jsx(Eye, { size: 15 }),
578
- " Preview: this is exactly what sends"
658
+ " ",
659
+ previewState.local ? "Local draft preview" : "Reply preview"
579
660
  ] }),
580
- /* @__PURE__ */ jsxs(DialogDescription, { children: [
661
+ /* @__PURE__ */ jsx(DialogDescription, { children: previewState.local ? "Local draft preview only \u2014 the server prepares the exact message on send." : /* @__PURE__ */ jsxs(Fragment, { children: [
581
662
  "Stays on the ",
582
663
  subject.replace(/^Re:\s*/i, ""),
583
664
  " thread. Gmail keeps it threaded."
584
- ] })
665
+ ] }) })
585
666
  ] }),
586
667
  /* @__PURE__ */ jsxs("div", { className: "border-border space-y-1 rounded-md border p-3 text-[13px]", children: [
587
668
  /* @__PURE__ */ jsxs("div", { children: [
@@ -603,30 +684,45 @@ function ReplyComposer({
603
684
  subject
604
685
  ] })
605
686
  ] }),
606
- /* @__PURE__ */ jsx("div", { className: cn(PROSE, "max-h-72 overflow-auto"), dangerouslySetInnerHTML: { __html: previewHtml } }),
687
+ previewState.loading ? /* @__PURE__ */ jsxs("div", { "data-slot": "conv-preview-loading", role: "status", className: "text-muted-foreground flex items-center gap-2 px-1 py-6 text-[13px]", children: [
688
+ /* @__PURE__ */ jsx("span", { className: "border-muted-foreground/40 border-t-foreground size-4 animate-spin rounded-full border-2", "aria-hidden": true }),
689
+ "Loading preview\u2026"
690
+ ] }) : previewState.error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: previewState.error }) : /* @__PURE__ */ jsx(
691
+ "div",
692
+ {
693
+ "data-slot": "conv-preview-body",
694
+ "data-confirmation-token": (_c = previewState.confirmationToken) != null ? _c : void 0,
695
+ className: cn(PROSE, "max-h-72 overflow-auto"),
696
+ dangerouslySetInnerHTML: { __html: (_d = previewState.html) != null ? _d : "" }
697
+ }
698
+ ),
607
699
  sendError ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: sendError }) : null,
608
700
  /* @__PURE__ */ jsxs(DialogFooter, { className: "sm:justify-between", children: [
609
- /* @__PURE__ */ jsxs(
701
+ /* @__PURE__ */ jsx("span", { className: "inline-flex", title: draftDisabledReason != null ? draftDisabledReason : void 0, children: /* @__PURE__ */ jsxs(
610
702
  "button",
611
703
  {
612
704
  type: "button",
613
- disabled: sending,
705
+ disabled: sending || previewState.loading || draftDisabled,
614
706
  onClick: handleDraft,
707
+ "aria-label": draftDisabledReason ? `Open draft in Gmail: ${draftDisabledReason}` : "Open draft in Gmail",
615
708
  className: "text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50",
616
709
  children: [
617
710
  /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
618
711
  " Open draft in Gmail"
619
712
  ]
620
713
  }
621
- ),
714
+ ) }),
622
715
  /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
623
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => setPreview(false), children: "Keep editing" }),
716
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => {
717
+ setPreview(false);
718
+ setPreviewState(IDLE_PREVIEW);
719
+ }, children: "Keep editing" }),
624
720
  /* @__PURE__ */ jsxs(
625
721
  Button,
626
722
  {
627
723
  type: "button",
628
724
  size: "sm",
629
- disabled: sending,
725
+ disabled: sending || previewState.loading,
630
726
  onClick: handleSend,
631
727
  children: [
632
728
  /* @__PURE__ */ jsx(Send, { size: 14 }),
@@ -647,9 +743,13 @@ function ThreadBody({
647
743
  tenantName,
648
744
  onSendReply,
649
745
  onCreateGmailDraft,
746
+ onPreviewReply,
650
747
  onOpenInGmail
651
748
  }) {
749
+ var _a;
652
750
  const canReply = thread.canReply !== false;
751
+ const replyDisabledReason = ((_a = thread.replyDisabledReason) == null ? void 0 : _a.trim()) || "You are not a participant on this thread, so replying is disabled here.";
752
+ const draftDisabledReason = onCreateGmailDraft ? null : "Gmail draft creation is not available for this thread.";
653
753
  const hasCc = !!(thread.cc && thread.cc.length);
654
754
  const [mode, setMode] = React.useState("idle");
655
755
  const [replyAll, setReplyAll] = React.useState(false);
@@ -661,7 +761,7 @@ function ThreadBody({
661
761
  return o;
662
762
  });
663
763
  const toggle = (id) => setExpanded((e) => __spreadProps(__spreadValues({}, e), { [id]: !e[id] }));
664
- return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread-body", className: "space-y-1.5", children: [
764
+ return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread-body", className: "space-y-2", children: [
665
765
  canReply && thread.paused ? /* @__PURE__ */ jsxs("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]", children: [
666
766
  /* @__PURE__ */ jsx(Pause, { size: 13, className: "mt-0.5 shrink-0" }),
667
767
  /* @__PURE__ */ jsxs("span", { children: [
@@ -674,14 +774,16 @@ function ThreadBody({
674
774
  ] })
675
775
  ] }) : null,
676
776
  /* @__PURE__ */ jsx("div", { className: "space-y-1", children: thread.messages.map((m) => /* @__PURE__ */ jsx(MessageView, { message: m, expanded: !!expanded[m.id], onToggle: () => toggle(m.id), me }, m.id)) }),
677
- !canReply ? /* @__PURE__ */ jsxs("div", { className: "border-border bg-muted/30 text-muted-foreground flex items-start gap-2 rounded-md border p-2.5 text-[12px]", children: [
777
+ !canReply ? /* @__PURE__ */ jsxs("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]", children: [
678
778
  /* @__PURE__ */ jsx(Eye, { size: 14, className: "mt-0.5 shrink-0" }),
679
- /* @__PURE__ */ jsxs("span", { children: [
779
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
680
780
  /* @__PURE__ */ jsx("b", { children: "Viewing only." }),
681
- " You\u2019re not a participant on this thread, so replying is disabled here."
682
- ] })
781
+ " ",
782
+ replyDisabledReason
783
+ ] }),
784
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail })
683
785
  ] }) : null,
684
- canReply && mode === "idle" ? /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
786
+ canReply && mode === "idle" ? /* @__PURE__ */ jsxs("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", children: [
685
787
  /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", onClick: () => {
686
788
  setReplyAll(false);
687
789
  setMode("replying");
@@ -696,12 +798,9 @@ function ThreadBody({
696
798
  /* @__PURE__ */ jsx(ReplyAll, { size: 14 }),
697
799
  " Reply all"
698
800
  ] }) : null,
699
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId), children: [
700
- /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
701
- " Open in Gmail"
702
- ] }),
703
- /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/70 ml-auto inline-flex items-center gap-1 text-[11px]", children: [
704
- /* @__PURE__ */ jsx(GitMerge, { size: 12 }),
801
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail }),
802
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/70 ml-auto inline-flex items-center gap-1.5 text-[12px]", children: [
803
+ /* @__PURE__ */ jsx(GitMerge, { size: 13 }),
705
804
  " Stays on this thread"
706
805
  ] })
707
806
  ] }) : null,
@@ -712,15 +811,18 @@ function ThreadBody({
712
811
  me,
713
812
  replyAll,
714
813
  tenantName,
814
+ onPreviewReply,
715
815
  onClose: () => setMode("idle"),
716
816
  onSend: async (body, includeSignature) => {
717
817
  await (onSendReply == null ? void 0 : onSendReply({ threadId: thread.threadId, body, includeSignature, replyAll }));
718
818
  setMode("sent");
719
819
  },
720
820
  onDraft: async (body, includeSignature) => {
721
- await (onCreateGmailDraft == null ? void 0 : onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll }));
821
+ if (!onCreateGmailDraft) return;
822
+ await onCreateGmailDraft({ threadId: thread.threadId, body, includeSignature, replyAll });
722
823
  setMode("draft");
723
- }
824
+ },
825
+ draftDisabledReason
724
826
  }
725
827
  ) : null,
726
828
  canReply && mode === "sent" ? /* @__PURE__ */ jsxs("div", { className: "border-status-active-border bg-status-active-bg flex items-center gap-2 rounded-md border p-3 text-[13px]", children: [
@@ -747,10 +849,7 @@ function ThreadBody({
747
849
  ] }),
748
850
  " thread; open it there to finish. Nothing was sent."
749
851
  ] }),
750
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenInGmail == null ? void 0 : onOpenInGmail(thread.threadId), children: [
751
- /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
752
- " Open in Gmail"
753
- ] }),
852
+ /* @__PURE__ */ jsx(OpenInGmailButton, { thread, onOpenInGmail }),
754
853
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setMode("idle"), children: "Done" })
755
854
  ] }) : null
756
855
  ] });
@@ -763,6 +862,7 @@ function ThreadRow({
763
862
  tenantName,
764
863
  onSendReply,
765
864
  onCreateGmailDraft,
865
+ onPreviewReply,
766
866
  onOpenInGmail
767
867
  }) {
768
868
  const status = effectiveStatus(thread);
@@ -782,8 +882,8 @@ function ThreadRow({
782
882
  /* @__PURE__ */ jsx("span", { className: cn("size-2 shrink-0 rounded-full", STATUS_DOT[status]), "aria-hidden": true }),
783
883
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
784
884
  /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
785
- /* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-semibold", children: thread.subject }),
786
- /* @__PURE__ */ jsx("span", { className: cn("shrink-0 rounded border px-1.5 text-[10px] font-medium leading-4", pill.cls), children: pill.label })
885
+ /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold", children: thread.subject }),
886
+ /* @__PURE__ */ jsx("span", { className: cn("shrink-0 rounded-md border px-1.5 py-px text-[10px] font-medium leading-4", pill.cls), children: pill.label })
787
887
  ] }),
788
888
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block truncate text-xs", children: [
789
889
  /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: thread.contact.name }),
@@ -806,6 +906,7 @@ function ThreadRow({
806
906
  tenantName,
807
907
  onSendReply,
808
908
  onCreateGmailDraft,
909
+ onPreviewReply,
809
910
  onOpenInGmail
810
911
  }
811
912
  ) }) : null
@@ -817,6 +918,7 @@ function ConversationPanel({
817
918
  tenantName,
818
919
  onSendReply,
819
920
  onCreateGmailDraft,
921
+ onPreviewReply,
820
922
  onOpenInGmail,
821
923
  defaultOpenThreadId,
822
924
  className
@@ -867,8 +969,8 @@ function ConversationPanel({
867
969
  }
868
970
  ),
869
971
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
870
- /* @__PURE__ */ jsx("span", { className: "block truncate text-[13px] font-semibold", children: headTitle }),
871
- /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block truncate text-xs", children: [
972
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-semibold leading-tight", children: headTitle }),
973
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground mt-0.5 block truncate text-xs", children: [
872
974
  threads.length,
873
975
  " ",
874
976
  threads.length === 1 ? "thread" : "threads",
@@ -893,6 +995,7 @@ function ConversationPanel({
893
995
  tenantName,
894
996
  onSendReply,
895
997
  onCreateGmailDraft,
998
+ onPreviewReply,
896
999
  onOpenInGmail
897
1000
  },
898
1001
  t.threadId