@clivly/chat-widget 0.13.0 → 0.15.0

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/index.mjs CHANGED
@@ -162,6 +162,13 @@ const WIDGET_STYLES = `
162
162
  .clivly-chat-widget textarea {
163
163
  font: inherit;
164
164
  }
165
+ /* The standalone embed does not load the app's shared stylesheet. */
166
+ @media (width < 48rem), (any-pointer: coarse) {
167
+ .clivly-chat-widget input,
168
+ .clivly-chat-widget textarea {
169
+ font-size: max(16px, 1em);
170
+ }
171
+ }
165
172
  .clivly-chat-widget__launcher {
166
173
  align-items: center;
167
174
  background: var(--clivly-chat-accent);
@@ -363,6 +370,55 @@ const WIDGET_STYLES = `
363
370
  font-size: 0.875rem;
364
371
  padding: 0.75rem;
365
372
  }
373
+ .clivly-chat-widget__notice {
374
+ background: var(--clivly-chat-operator);
375
+ border-radius: 0.8rem;
376
+ color: var(--clivly-chat-muted);
377
+ font-size: 0.8125rem;
378
+ margin: 0;
379
+ padding: 0.625rem 0.75rem;
380
+ }
381
+ /* Automated and AI messages are marked as such, never styled as a teammate. */
382
+ .clivly-chat-widget__message[data-automation] {
383
+ background: transparent;
384
+ border: 1px dashed var(--clivly-chat-border);
385
+ }
386
+ .clivly-chat-widget__badge {
387
+ border: 1px solid var(--clivly-chat-border);
388
+ border-radius: 999px;
389
+ font-size: 0.6875rem;
390
+ padding: 0 0.4rem;
391
+ }
392
+ .clivly-chat-widget__topics {
393
+ display: flex;
394
+ flex-wrap: wrap;
395
+ gap: 0.5rem;
396
+ margin: 0;
397
+ padding: 0;
398
+ list-style: none;
399
+ }
400
+ .clivly-chat-widget__topic {
401
+ background: var(--clivly-chat-panel);
402
+ border: 1px solid var(--clivly-chat-accent);
403
+ border-radius: 999px;
404
+ color: var(--clivly-chat-text);
405
+ cursor: pointer;
406
+ font-size: 0.875rem;
407
+ min-height: 2.25rem;
408
+ padding: 0.4rem 0.85rem;
409
+ }
410
+ .clivly-chat-widget__topic:hover,
411
+ .clivly-chat-widget__topic:focus-visible {
412
+ background: var(--clivly-chat-visitor);
413
+ }
414
+ .clivly-chat-widget__topic:focus-visible {
415
+ outline: 2px solid var(--clivly-chat-accent);
416
+ outline-offset: 2px;
417
+ }
418
+ .clivly-chat-widget__topic[disabled] {
419
+ cursor: not-allowed;
420
+ opacity: 0.55;
421
+ }
366
422
  .clivly-chat-widget__empty {
367
423
  color: var(--clivly-chat-muted);
368
424
  font-size: 0.875rem;
@@ -459,8 +515,15 @@ function useResolvedTheme(theme) {
459
515
  * protocol reads as a single unit, and so `ChatWidget` stays inside the
460
516
  * cognitive-complexity budget.
461
517
  */
462
- function applyServerEvent(event, { setError, setIsReady, setMessages }) {
518
+ function applyServerEvent(event, actions) {
519
+ const { setError, setIsReady, setMessages } = actions;
463
520
  switch (event.type) {
521
+ case "assistant.status":
522
+ actions.setAssistantThinking(event.status === "thinking");
523
+ return;
524
+ case "support.state":
525
+ actions.setSupportState(event.state);
526
+ return;
464
527
  case "conversation.snapshot":
465
528
  setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
466
529
  return;
@@ -481,14 +544,41 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
481
544
  default: return;
482
545
  }
483
546
  }
547
+ /**
548
+ * What a message from the support assistant is called. Automated
549
+ * acknowledgements, AI answers and people are never presented alike, so a
550
+ * visitor is never led to think a person has read their message.
551
+ */
552
+ const AUTOMATION_BADGES = {
553
+ ai: "AI assistant",
554
+ ai_clarify: "AI assistant",
555
+ greeting: "Automated",
556
+ handoff: "Automated",
557
+ topic: "Automated"
558
+ };
559
+ const AUTHOR_LABEL_SUFFIX = / · (?:Automated|AI assistant)$/;
560
+ function automationBadge(message) {
561
+ if (message.senderType !== "system" || !message.automation) return null;
562
+ return AUTOMATION_BADGES[message.automation] ?? "Automated";
563
+ }
484
564
  function MessageBubble({ message }) {
565
+ const badge = automationBadge(message);
566
+ const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
485
567
  return /* @__PURE__ */ jsxs("article", {
486
568
  className: "clivly-chat-widget__message",
569
+ "data-automation": badge ? message.automation : void 0,
487
570
  "data-sender": message.senderType,
488
571
  children: [
489
572
  /* @__PURE__ */ jsxs("div", {
490
573
  className: "clivly-chat-widget__message-meta",
491
- children: [/* @__PURE__ */ jsx("span", { children: message.author ?? "Clivly" }), /* @__PURE__ */ jsx("span", { children: formatTimestamp(message.createdAt) })]
574
+ children: [
575
+ /* @__PURE__ */ jsx("span", { children: author || "Clivly" }),
576
+ badge ? /* @__PURE__ */ jsx("span", {
577
+ className: "clivly-chat-widget__badge",
578
+ children: badge
579
+ }) : null,
580
+ /* @__PURE__ */ jsx("span", { children: formatTimestamp(message.createdAt) })
581
+ ]
492
582
  }),
493
583
  /* @__PURE__ */ jsx("div", { children: message.body }),
494
584
  message.delivery === "sent" ? null : /* @__PURE__ */ jsx("div", {
@@ -515,6 +605,57 @@ function MessageList({ greeting, hasSession, messages }) {
515
605
  return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)) });
516
606
  }
517
607
  /**
608
+ * How often an open, visible widget re-sends its read receipt. The room drops
609
+ * a receipt that arrives within 5 s of the last one, so a reply read in that
610
+ * window would otherwise stay "unread" and be emailed to the visitor.
611
+ */
612
+ const READ_RECEIPT_REFRESH_MS = 15e3;
613
+ /** How long a visitor waits unanswered before the widget reassures them (D20a). */
614
+ const WAITING_NOTICE_AFTER_MS = 12e4;
615
+ /**
616
+ * When the visitor's latest message was sent, if nobody has answered it yet;
617
+ * null when the latest message is a reply or there is nothing waiting. Failed
618
+ * sends don't count: nobody was notified about them.
619
+ */
620
+ function awaitingReplySince(messages) {
621
+ for (let index = messages.length - 1; index >= 0; index--) {
622
+ const message = messages[index];
623
+ if (!message || message.delivery === "failed") continue;
624
+ if (message.senderType === "system" || message.automation === "topic_choice") continue;
625
+ if (message.senderType !== "visitor") return null;
626
+ return new Date(message.createdAt).getTime();
627
+ }
628
+ return null;
629
+ }
630
+ /** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
631
+ function useWaitingNotice(messages) {
632
+ const [due, setDue] = useState(false);
633
+ useEffect(() => {
634
+ const since = awaitingReplySince(messages);
635
+ if (since === null) {
636
+ setDue(false);
637
+ return;
638
+ }
639
+ const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
640
+ if (remaining <= 0) {
641
+ setDue(true);
642
+ return;
643
+ }
644
+ setDue(false);
645
+ const timer = setTimeout(() => {
646
+ setDue(true);
647
+ }, remaining);
648
+ return () => {
649
+ clearTimeout(timer);
650
+ };
651
+ }, [messages]);
652
+ return due;
653
+ }
654
+ function waitingNoticeText(email) {
655
+ const address = email?.trim();
656
+ return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
657
+ }
658
+ /**
518
659
  * Whether a seeded visitor is complete enough to skip the form.
519
660
  *
520
661
  * Both fields have to be present and non-blank: the server requires each, so
@@ -534,6 +675,70 @@ function composerStatusText(hasTransport, isReady) {
534
675
  if (!hasTransport) return "Chat is unavailable";
535
676
  return isReady ? "Conversation ready" : "Connecting…";
536
677
  }
678
+ /**
679
+ * Guided options under the greeting. Native buttons in a labelled group, so
680
+ * they are reachable with Tab and operable with Enter/Space; free text stays
681
+ * available beside them the whole time.
682
+ */
683
+ function SupportTopics({ disabled, onSelect, topics }) {
684
+ return /* @__PURE__ */ jsx("ul", {
685
+ "aria-label": "Suggested topics",
686
+ className: "clivly-chat-widget__topics",
687
+ children: topics.map((topic) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
688
+ className: "clivly-chat-widget__topic",
689
+ disabled,
690
+ onClick: () => {
691
+ onSelect(topic);
692
+ },
693
+ type: "button",
694
+ children: topic.label
695
+ }) }, topic.id))
696
+ });
697
+ }
698
+ /** Topics are offered only while automation is answering this conversation. */
699
+ function visibleTopics(session, state) {
700
+ return state === "automated" ? session?.support?.topics ?? [] : [];
701
+ }
702
+ function composerPlaceholder(hasTopics) {
703
+ return hasTopics ? "Or type your question" : "Write your message";
704
+ }
705
+ /**
706
+ * Hand one visitor message to the transport: a topic click as `topic.select`
707
+ * when the transport supports it (the server resolves the answer), otherwise
708
+ * as the label, typed. Throws what the transport throws.
709
+ */
710
+ function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
711
+ if (topic && transport.selectTopic) {
712
+ transport.selectTopic({
713
+ id: clientMessageId,
714
+ topicId: topic.id,
715
+ type: "topic.select"
716
+ });
717
+ return;
718
+ }
719
+ transport.sendMessage({
720
+ body,
721
+ id: clientMessageId,
722
+ type: "message.create"
723
+ });
724
+ }
725
+ /**
726
+ * One honest status line: the AI is working on an answer, or — once the
727
+ * visitor has waited — that the team has been told. Never both.
728
+ */
729
+ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
730
+ if (assistantThinking) return /* @__PURE__ */ jsx("p", {
731
+ className: "clivly-chat-widget__notice",
732
+ role: "status",
733
+ children: "The AI assistant is looking for an answer…"
734
+ });
735
+ if (!showWaitingNotice) return null;
736
+ return /* @__PURE__ */ jsx("p", {
737
+ className: "clivly-chat-widget__notice",
738
+ role: "status",
739
+ children: waitingNoticeText(email)
740
+ });
741
+ }
537
742
  function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
538
743
  const [isOpen, setIsOpen] = useState(initiallyOpen);
539
744
  const [error, setError] = useState(null);
@@ -542,6 +747,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
542
747
  const [messageDraft, setMessageDraft] = useState("");
543
748
  const [messages, setMessages] = useState([]);
544
749
  const [session, setSession] = useState(null);
750
+ const [supportState, setSupportState] = useState(null);
751
+ const [assistantThinking, setAssistantThinking] = useState(false);
545
752
  const [visitor, setVisitor] = useState({
546
753
  email: defaultVisitor?.email ?? "",
547
754
  name: defaultVisitor?.name ?? ""
@@ -552,6 +759,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
552
759
  if (createTransport) return;
553
760
  console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
554
761
  }, []);
762
+ const showWaitingNotice = useWaitingNotice(messages);
555
763
  const themeStyle = useThemeStyle(session, style);
556
764
  const resolvedTheme = useResolvedTheme(theme);
557
765
  useEffect(() => () => {
@@ -571,15 +779,18 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
571
779
  handlers: {
572
780
  onClose: () => {
573
781
  setIsReady(false);
782
+ setAssistantThinking(false);
574
783
  },
575
784
  onError: (transportError) => {
576
785
  setError(transportError.message);
577
786
  },
578
787
  onEvent: (event) => {
579
788
  applyServerEvent(event, {
789
+ setAssistantThinking,
580
790
  setError,
581
791
  setIsReady,
582
- setMessages
792
+ setMessages,
793
+ setSupportState
583
794
  });
584
795
  },
585
796
  onOpen: () => {
@@ -594,6 +805,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
594
805
  if (transportRef.current === connection) transportRef.current = null;
595
806
  };
596
807
  }, [createTransport, session]);
808
+ useEffect(() => {
809
+ if (!(isOpen && isReady)) return;
810
+ const markReadIfVisible = () => {
811
+ if (document.visibilityState === "visible") transportRef.current?.markRead?.();
812
+ };
813
+ markReadIfVisible();
814
+ const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
815
+ document.addEventListener("visibilitychange", markReadIfVisible);
816
+ return () => {
817
+ window.clearInterval(interval);
818
+ document.removeEventListener("visibilitychange", markReadIfVisible);
819
+ };
820
+ }, [
821
+ isOpen,
822
+ isReady,
823
+ messages
824
+ ]);
597
825
  const startSession = useCallback(async (identity) => {
598
826
  setError(null);
599
827
  setIsConnecting(true);
@@ -604,6 +832,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
604
832
  widgetId
605
833
  });
606
834
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
835
+ setSupportState(nextSession.support?.state ?? null);
607
836
  setSession(nextSession);
608
837
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
609
838
  } catch (sessionError) {
@@ -636,41 +865,48 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
636
865
  session,
637
866
  startSession
638
867
  ]);
639
- const submitMessage = (event) => {
640
- event?.preventDefault();
641
- if (!(session && transportRef.current)) {
868
+ /**
869
+ * Show the visitor's message at once and hand it to the transport. A topic
870
+ * click goes as `topic.select` when the transport supports it, so the
871
+ * server resolves the answer; otherwise as the label, typed.
872
+ */
873
+ const sendVisitorMessage = (body, topic) => {
874
+ const transport = transportRef.current;
875
+ if (!(session && transport)) {
642
876
  setError("Chat is not connected yet.");
643
877
  return;
644
878
  }
645
- const body = messageDraft.trim();
646
- if (!body) return;
647
879
  const clientMessageId = generateId();
648
880
  const optimisticMessage = {
649
881
  author: sessionDisplayName(session),
882
+ automation: topic && transport.selectTopic ? "topic_choice" : null,
650
883
  body,
651
884
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
652
885
  id: clientMessageId,
653
886
  senderType: "visitor"
654
887
  };
655
888
  setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
656
- setMessageDraft("");
657
889
  setError(null);
658
890
  try {
659
- transportRef.current.sendMessage({
660
- body,
661
- id: clientMessageId,
662
- type: "message.create"
663
- });
891
+ dispatchVisitorMessage(transport, clientMessageId, body, topic);
664
892
  } catch (transportError) {
665
893
  const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
666
894
  setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
667
895
  setError(resolved.message);
668
896
  }
669
897
  };
898
+ const submitMessage = (event) => {
899
+ event?.preventDefault();
900
+ const body = messageDraft.trim();
901
+ if (!body) return;
902
+ setMessageDraft("");
903
+ sendVisitorMessage(body);
904
+ };
670
905
  const canSend = Boolean(createTransport) && isReady;
671
906
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
672
907
  const panelTitle = session?.widget.name ?? "Start a conversation";
673
908
  const sessionSummary = sessionSummaryText(session, greeting);
909
+ const topics = visibleTopics(session, supportState);
674
910
  return /* @__PURE__ */ jsxs("div", {
675
911
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
676
912
  "data-clivly-theme": resolvedTheme,
@@ -708,16 +944,33 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
708
944
  })]
709
945
  }),
710
946
  /* @__PURE__ */ jsxs("div", {
947
+ "aria-label": "Conversation",
711
948
  className: "clivly-chat-widget__body",
712
949
  ref: listRef,
713
- children: [error ? /* @__PURE__ */ jsx("div", {
714
- className: "clivly-chat-widget__error",
715
- children: error
716
- }) : null, /* @__PURE__ */ jsx(MessageList, {
717
- greeting,
718
- hasSession: Boolean(session),
719
- messages
720
- })]
950
+ role: "log",
951
+ children: [
952
+ error ? /* @__PURE__ */ jsx("div", {
953
+ className: "clivly-chat-widget__error",
954
+ children: error
955
+ }) : null,
956
+ /* @__PURE__ */ jsx(MessageList, {
957
+ greeting,
958
+ hasSession: Boolean(session),
959
+ messages
960
+ }),
961
+ topics.length > 0 ? /* @__PURE__ */ jsx(SupportTopics, {
962
+ disabled: !canSend,
963
+ onSelect: (topic) => {
964
+ sendVisitorMessage(topic.label, topic);
965
+ },
966
+ topics
967
+ }) : null,
968
+ session ? /* @__PURE__ */ jsx(StatusNotice, {
969
+ assistantThinking,
970
+ email: session.contact?.email,
971
+ showWaitingNotice
972
+ }) : null
973
+ ]
721
974
  }),
722
975
  session ? /* @__PURE__ */ jsxs("form", {
723
976
  className: "clivly-chat-widget__composer",
@@ -737,7 +990,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
737
990
  event.preventDefault();
738
991
  if (messageDraft.trim() && canSend) submitMessage();
739
992
  },
740
- placeholder: "Write your message",
993
+ placeholder: composerPlaceholder(topics.length > 0),
741
994
  value: messageDraft
742
995
  })]
743
996
  }), /* @__PURE__ */ jsxs("div", {
@@ -875,6 +1128,13 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
875
1128
  close: () => {
876
1129
  socket.close();
877
1130
  },
1131
+ markRead: () => {
1132
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
1133
+ },
1134
+ selectTopic: (event) => {
1135
+ if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
1136
+ socket.send(JSON.stringify(event));
1137
+ },
878
1138
  sendMessage: (message) => {
879
1139
  if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
880
1140
  socket.send(JSON.stringify(message));