@handled-ai/design-system 0.20.3 → 0.20.4

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":[]}
@@ -77,6 +77,16 @@ interface ConversationReplyPayload {
77
77
  includeSignature: boolean;
78
78
  replyAll: boolean;
79
79
  }
80
+ /**
81
+ * Result of a server-side reply preview. `htmlBody` is the exact HTML the server
82
+ * prepared for this reply (sanitized by the component before render).
83
+ * `confirmationToken`, when present, identifies the prepared confirmation so a
84
+ * consumer can reuse it for the final send instead of re-preparing the message.
85
+ */
86
+ interface ConversationReplyPreview {
87
+ htmlBody: string;
88
+ confirmationToken?: string;
89
+ }
80
90
  interface ConversationPanelProps {
81
91
  threads: ConversationThread[];
82
92
  /** Current operator: drives "to me" + the reply avatar. */
@@ -85,11 +95,19 @@ interface ConversationPanelProps {
85
95
  tenantName?: string;
86
96
  onSendReply?: (payload: ConversationReplyPayload) => void | Promise<void>;
87
97
  onCreateGmailDraft?: (payload: ConversationReplyPayload) => void | Promise<void>;
98
+ /**
99
+ * Server-side preview contract. When provided, the reply preview requests the
100
+ * exact send HTML from the server, the component sanitizes it before render,
101
+ * and retains any returned `confirmationToken` in preview state so a consumer
102
+ * can reuse it for the final send. When omitted, the composer falls back to a
103
+ * clearly labeled local draft preview only.
104
+ */
105
+ onPreviewReply?: (payload: ConversationReplyPayload) => Promise<ConversationReplyPreview>;
88
106
  onOpenInGmail?: (threadId: string) => void;
89
107
  /** Inline-open this thread initially (defaults to the first responded one). */
90
108
  defaultOpenThreadId?: string;
91
109
  className?: string;
92
110
  }
93
- declare function ConversationPanel({ threads, me, tenantName, onSendReply, onCreateGmailDraft, onOpenInGmail, defaultOpenThreadId, className, }: ConversationPanelProps): React.JSX.Element | null;
111
+ declare function ConversationPanel({ threads, me, tenantName, onSendReply, onCreateGmailDraft, onPreviewReply, onOpenInGmail, defaultOpenThreadId, className, }: ConversationPanelProps): React.JSX.Element | null;
94
112
 
95
- export { type ConvMessage, type ConvParticipant, type ConvStatus, ConversationPanel, type ConversationPanelProps, type ConversationReplyPayload, type ConversationThread };
113
+ 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
  };
@@ -453,6 +453,7 @@ function MessageView({
453
453
  ] })
454
454
  ] });
455
455
  }
456
+ const IDLE_PREVIEW = { loading: false, html: null, confirmationToken: null, error: null, local: false };
456
457
  function ReplyComposer({
457
458
  thread,
458
459
  me,
@@ -460,23 +461,54 @@ function ReplyComposer({
460
461
  tenantName,
461
462
  onClose,
462
463
  onSend,
463
- onDraft
464
+ onDraft,
465
+ onPreviewReply
464
466
  }) {
465
- var _a, _b;
467
+ var _a, _b, _c, _d;
466
468
  const [body, setBody] = React.useState((_a = thread.draft) != null ? _a : "");
467
469
  const [sig, setSig] = React.useState(true);
468
470
  const [preview, setPreview] = React.useState(false);
471
+ const [previewState, setPreviewState] = React.useState(IDLE_PREVIEW);
469
472
  const [sending, setSending] = React.useState(false);
470
473
  const [sendError, setSendError] = React.useState(null);
471
474
  const ccList = replyAll ? (_b = thread.cc) != null ? _b : [] : [];
472
475
  const subject = /^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`;
473
- const previewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "");
476
+ const localPreviewHtml = textToHtml(body) + (sig && thread.signature ? textToHtml(thread.signature) : "");
477
+ const openPreview = async () => {
478
+ var _a2, _b2;
479
+ setPreview(true);
480
+ setSendError(null);
481
+ if (!onPreviewReply) {
482
+ setPreviewState({ loading: false, html: sanitizeHtml(localPreviewHtml), confirmationToken: null, error: null, local: true });
483
+ return;
484
+ }
485
+ setPreviewState({ loading: true, html: null, confirmationToken: null, error: null, local: false });
486
+ try {
487
+ const result = await onPreviewReply({ threadId: thread.threadId, body, includeSignature: sig, replyAll });
488
+ setPreviewState({
489
+ loading: false,
490
+ html: sanitizeHtml((_a2 = result.htmlBody) != null ? _a2 : ""),
491
+ confirmationToken: (_b2 = result.confirmationToken) != null ? _b2 : null,
492
+ error: null,
493
+ local: false
494
+ });
495
+ } catch (error) {
496
+ setPreviewState({
497
+ loading: false,
498
+ html: null,
499
+ confirmationToken: null,
500
+ error: error instanceof Error ? error.message : "Could not load the preview. Please try again.",
501
+ local: false
502
+ });
503
+ }
504
+ };
474
505
  const handleSend = async () => {
475
506
  setSending(true);
476
507
  setSendError(null);
477
508
  try {
478
509
  await onSend(body, sig);
479
510
  setPreview(false);
511
+ setPreviewState(IDLE_PREVIEW);
480
512
  } catch (error) {
481
513
  setSendError(error instanceof Error ? error.message : "Could not send this reply. Please try again.");
482
514
  } finally {
@@ -489,6 +521,7 @@ function ReplyComposer({
489
521
  try {
490
522
  await onDraft(body, sig);
491
523
  setPreview(false);
524
+ setPreviewState(IDLE_PREVIEW);
492
525
  } catch (error) {
493
526
  setSendError(error instanceof Error ? error.message : "Could not create the Gmail draft. Please try again.");
494
527
  } finally {
@@ -545,7 +578,7 @@ function ReplyComposer({
545
578
  onKeyDown: (e) => {
546
579
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
547
580
  e.preventDefault();
548
- setPreview(true);
581
+ void openPreview();
549
582
  }
550
583
  }
551
584
  }
@@ -560,28 +593,32 @@ function ReplyComposer({
560
593
  /* @__PURE__ */ jsx(Switch, { checked: sig, onCheckedChange: setSig, "aria-label": "Toggle signature" }),
561
594
  "Signature"
562
595
  ] }),
563
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => setPreview(true), children: [
596
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => void openPreview(), children: [
564
597
  /* @__PURE__ */ jsx(Eye, { size: 14 }),
565
598
  " Preview"
566
599
  ] }),
567
- /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", disabled: sending, onClick: () => setPreview(true), children: [
600
+ /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", disabled: sending, onClick: () => void openPreview(), children: [
568
601
  /* @__PURE__ */ jsx(Send, { size: 14 }),
569
602
  " Send"
570
603
  ] })
571
604
  ] }),
572
605
  /* @__PURE__ */ jsx(Dialog, { open: preview, onOpenChange: (open) => {
573
- if (!sending) setPreview(open);
606
+ if (!sending) {
607
+ setPreview(open);
608
+ if (!open) setPreviewState(IDLE_PREVIEW);
609
+ }
574
610
  }, children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-w-xl", children: [
575
611
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
576
612
  /* @__PURE__ */ jsxs(DialogTitle, { className: "flex items-center gap-1.5 text-[15px]", children: [
577
613
  /* @__PURE__ */ jsx(Eye, { size: 15 }),
578
- " Preview: this is exactly what sends"
614
+ " ",
615
+ previewState.local ? "Local draft preview" : "Reply preview"
579
616
  ] }),
580
- /* @__PURE__ */ jsxs(DialogDescription, { children: [
617
+ /* @__PURE__ */ jsx(DialogDescription, { children: previewState.local ? "Local draft preview only \u2014 the server prepares the exact message on send." : /* @__PURE__ */ jsxs(Fragment, { children: [
581
618
  "Stays on the ",
582
619
  subject.replace(/^Re:\s*/i, ""),
583
620
  " thread. Gmail keeps it threaded."
584
- ] })
621
+ ] }) })
585
622
  ] }),
586
623
  /* @__PURE__ */ jsxs("div", { className: "border-border space-y-1 rounded-md border p-3 text-[13px]", children: [
587
624
  /* @__PURE__ */ jsxs("div", { children: [
@@ -603,14 +640,25 @@ function ReplyComposer({
603
640
  subject
604
641
  ] })
605
642
  ] }),
606
- /* @__PURE__ */ jsx("div", { className: cn(PROSE, "max-h-72 overflow-auto"), dangerouslySetInnerHTML: { __html: previewHtml } }),
643
+ 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: [
644
+ /* @__PURE__ */ jsx("span", { className: "border-muted-foreground/40 border-t-foreground size-4 animate-spin rounded-full border-2", "aria-hidden": true }),
645
+ "Loading preview\u2026"
646
+ ] }) : previewState.error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: previewState.error }) : /* @__PURE__ */ jsx(
647
+ "div",
648
+ {
649
+ "data-slot": "conv-preview-body",
650
+ "data-confirmation-token": (_c = previewState.confirmationToken) != null ? _c : void 0,
651
+ className: cn(PROSE, "max-h-72 overflow-auto"),
652
+ dangerouslySetInnerHTML: { __html: (_d = previewState.html) != null ? _d : "" }
653
+ }
654
+ ),
607
655
  sendError ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-destructive text-sm", children: sendError }) : null,
608
656
  /* @__PURE__ */ jsxs(DialogFooter, { className: "sm:justify-between", children: [
609
657
  /* @__PURE__ */ jsxs(
610
658
  "button",
611
659
  {
612
660
  type: "button",
613
- disabled: sending,
661
+ disabled: sending || previewState.loading,
614
662
  onClick: handleDraft,
615
663
  className: "text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-[13px] disabled:pointer-events-none disabled:opacity-50",
616
664
  children: [
@@ -620,13 +668,16 @@ function ReplyComposer({
620
668
  }
621
669
  ),
622
670
  /* @__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" }),
671
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", disabled: sending, onClick: () => {
672
+ setPreview(false);
673
+ setPreviewState(IDLE_PREVIEW);
674
+ }, children: "Keep editing" }),
624
675
  /* @__PURE__ */ jsxs(
625
676
  Button,
626
677
  {
627
678
  type: "button",
628
679
  size: "sm",
629
- disabled: sending,
680
+ disabled: sending || previewState.loading,
630
681
  onClick: handleSend,
631
682
  children: [
632
683
  /* @__PURE__ */ jsx(Send, { size: 14 }),
@@ -647,6 +698,7 @@ function ThreadBody({
647
698
  tenantName,
648
699
  onSendReply,
649
700
  onCreateGmailDraft,
701
+ onPreviewReply,
650
702
  onOpenInGmail
651
703
  }) {
652
704
  const canReply = thread.canReply !== false;
@@ -661,7 +713,7 @@ function ThreadBody({
661
713
  return o;
662
714
  });
663
715
  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: [
716
+ return /* @__PURE__ */ jsxs("div", { "data-slot": "conv-thread-body", className: "space-y-2", children: [
665
717
  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
718
  /* @__PURE__ */ jsx(Pause, { size: 13, className: "mt-0.5 shrink-0" }),
667
719
  /* @__PURE__ */ jsxs("span", { children: [
@@ -681,7 +733,7 @@ function ThreadBody({
681
733
  " You\u2019re not a participant on this thread, so replying is disabled here."
682
734
  ] })
683
735
  ] }) : null,
684
- canReply && mode === "idle" ? /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
736
+ 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
737
  /* @__PURE__ */ jsxs(Button, { type: "button", size: "sm", onClick: () => {
686
738
  setReplyAll(false);
687
739
  setMode("replying");
@@ -700,8 +752,8 @@ function ThreadBody({
700
752
  /* @__PURE__ */ jsx(GmailMark, { size: 14 }),
701
753
  " Open in Gmail"
702
754
  ] }),
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 }),
755
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground/70 ml-auto inline-flex items-center gap-1.5 text-[12px]", children: [
756
+ /* @__PURE__ */ jsx(GitMerge, { size: 13 }),
705
757
  " Stays on this thread"
706
758
  ] })
707
759
  ] }) : null,
@@ -712,6 +764,7 @@ function ThreadBody({
712
764
  me,
713
765
  replyAll,
714
766
  tenantName,
767
+ onPreviewReply,
715
768
  onClose: () => setMode("idle"),
716
769
  onSend: async (body, includeSignature) => {
717
770
  await (onSendReply == null ? void 0 : onSendReply({ threadId: thread.threadId, body, includeSignature, replyAll }));
@@ -763,6 +816,7 @@ function ThreadRow({
763
816
  tenantName,
764
817
  onSendReply,
765
818
  onCreateGmailDraft,
819
+ onPreviewReply,
766
820
  onOpenInGmail
767
821
  }) {
768
822
  const status = effectiveStatus(thread);
@@ -782,8 +836,8 @@ function ThreadRow({
782
836
  /* @__PURE__ */ jsx("span", { className: cn("size-2 shrink-0 rounded-full", STATUS_DOT[status]), "aria-hidden": true }),
783
837
  /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
784
838
  /* @__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 })
839
+ /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold", children: thread.subject }),
840
+ /* @__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
841
  ] }),
788
842
  /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground block truncate text-xs", children: [
789
843
  /* @__PURE__ */ jsx("b", { className: "text-foreground/80", children: thread.contact.name }),
@@ -806,6 +860,7 @@ function ThreadRow({
806
860
  tenantName,
807
861
  onSendReply,
808
862
  onCreateGmailDraft,
863
+ onPreviewReply,
809
864
  onOpenInGmail
810
865
  }
811
866
  ) }) : null
@@ -817,6 +872,7 @@ function ConversationPanel({
817
872
  tenantName,
818
873
  onSendReply,
819
874
  onCreateGmailDraft,
875
+ onPreviewReply,
820
876
  onOpenInGmail,
821
877
  defaultOpenThreadId,
822
878
  className
@@ -867,8 +923,8 @@ function ConversationPanel({
867
923
  }
868
924
  ),
869
925
  /* @__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: [
926
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-semibold leading-tight", children: headTitle }),
927
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground mt-0.5 block truncate text-xs", children: [
872
928
  threads.length,
873
929
  " ",
874
930
  threads.length === 1 ? "thread" : "threads",
@@ -893,6 +949,7 @@ function ConversationPanel({
893
949
  tenantName,
894
950
  onSendReply,
895
951
  onCreateGmailDraft,
952
+ onPreviewReply,
896
953
  onOpenInGmail
897
954
  },
898
955
  t.threadId