@clivly/chat-widget 0.14.1 → 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.
@@ -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);
@@ -371,6 +378,47 @@ const WIDGET_STYLES = `
371
378
  margin: 0;
372
379
  padding: 0.625rem 0.75rem;
373
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
+ }
374
422
  .clivly-chat-widget__empty {
375
423
  color: var(--clivly-chat-muted);
376
424
  font-size: 0.875rem;
@@ -467,8 +515,15 @@ function useResolvedTheme(theme) {
467
515
  * protocol reads as a single unit, and so `ChatWidget` stays inside the
468
516
  * cognitive-complexity budget.
469
517
  */
470
- function applyServerEvent(event, { setError, setIsReady, setMessages }) {
518
+ function applyServerEvent(event, actions) {
519
+ const { setError, setIsReady, setMessages } = actions;
471
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;
472
527
  case "conversation.snapshot":
473
528
  setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
474
529
  return;
@@ -489,14 +544,41 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
489
544
  default: return;
490
545
  }
491
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
+ }
492
564
  function MessageBubble({ message }) {
565
+ const badge = automationBadge(message);
566
+ const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
493
567
  return /* @__PURE__ */ jsxs("article", {
494
568
  className: "clivly-chat-widget__message",
569
+ "data-automation": badge ? message.automation : void 0,
495
570
  "data-sender": message.senderType,
496
571
  children: [
497
572
  /* @__PURE__ */ jsxs("div", {
498
573
  className: "clivly-chat-widget__message-meta",
499
- 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
+ ]
500
582
  }),
501
583
  /* @__PURE__ */ jsx("div", { children: message.body }),
502
584
  message.delivery === "sent" ? null : /* @__PURE__ */ jsx("div", {
@@ -539,6 +621,7 @@ function awaitingReplySince(messages) {
539
621
  for (let index = messages.length - 1; index >= 0; index--) {
540
622
  const message = messages[index];
541
623
  if (!message || message.delivery === "failed") continue;
624
+ if (message.senderType === "system" || message.automation === "topic_choice") continue;
542
625
  if (message.senderType !== "visitor") return null;
543
626
  return new Date(message.createdAt).getTime();
544
627
  }
@@ -592,6 +675,70 @@ function composerStatusText(hasTransport, isReady) {
592
675
  if (!hasTransport) return "Chat is unavailable";
593
676
  return isReady ? "Conversation ready" : "Connecting…";
594
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
+ }
595
742
  function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
596
743
  const [isOpen, setIsOpen] = useState(initiallyOpen);
597
744
  const [error, setError] = useState(null);
@@ -600,6 +747,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
600
747
  const [messageDraft, setMessageDraft] = useState("");
601
748
  const [messages, setMessages] = useState([]);
602
749
  const [session, setSession] = useState(null);
750
+ const [supportState, setSupportState] = useState(null);
751
+ const [assistantThinking, setAssistantThinking] = useState(false);
603
752
  const [visitor, setVisitor] = useState({
604
753
  email: defaultVisitor?.email ?? "",
605
754
  name: defaultVisitor?.name ?? ""
@@ -630,15 +779,18 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
630
779
  handlers: {
631
780
  onClose: () => {
632
781
  setIsReady(false);
782
+ setAssistantThinking(false);
633
783
  },
634
784
  onError: (transportError) => {
635
785
  setError(transportError.message);
636
786
  },
637
787
  onEvent: (event) => {
638
788
  applyServerEvent(event, {
789
+ setAssistantThinking,
639
790
  setError,
640
791
  setIsReady,
641
- setMessages
792
+ setMessages,
793
+ setSupportState
642
794
  });
643
795
  },
644
796
  onOpen: () => {
@@ -680,6 +832,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
680
832
  widgetId
681
833
  });
682
834
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
835
+ setSupportState(nextSession.support?.state ?? null);
683
836
  setSession(nextSession);
684
837
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
685
838
  } catch (sessionError) {
@@ -712,41 +865,48 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
712
865
  session,
713
866
  startSession
714
867
  ]);
715
- const submitMessage = (event) => {
716
- event?.preventDefault();
717
- 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)) {
718
876
  setError("Chat is not connected yet.");
719
877
  return;
720
878
  }
721
- const body = messageDraft.trim();
722
- if (!body) return;
723
879
  const clientMessageId = generateId();
724
880
  const optimisticMessage = {
725
881
  author: sessionDisplayName(session),
882
+ automation: topic && transport.selectTopic ? "topic_choice" : null,
726
883
  body,
727
884
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
728
885
  id: clientMessageId,
729
886
  senderType: "visitor"
730
887
  };
731
888
  setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
732
- setMessageDraft("");
733
889
  setError(null);
734
890
  try {
735
- transportRef.current.sendMessage({
736
- body,
737
- id: clientMessageId,
738
- type: "message.create"
739
- });
891
+ dispatchVisitorMessage(transport, clientMessageId, body, topic);
740
892
  } catch (transportError) {
741
893
  const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
742
894
  setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
743
895
  setError(resolved.message);
744
896
  }
745
897
  };
898
+ const submitMessage = (event) => {
899
+ event?.preventDefault();
900
+ const body = messageDraft.trim();
901
+ if (!body) return;
902
+ setMessageDraft("");
903
+ sendVisitorMessage(body);
904
+ };
746
905
  const canSend = Boolean(createTransport) && isReady;
747
906
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
748
907
  const panelTitle = session?.widget.name ?? "Start a conversation";
749
908
  const sessionSummary = sessionSummaryText(session, greeting);
909
+ const topics = visibleTopics(session, supportState);
750
910
  return /* @__PURE__ */ jsxs("div", {
751
911
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
752
912
  "data-clivly-theme": resolvedTheme,
@@ -784,8 +944,10 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
784
944
  })]
785
945
  }),
786
946
  /* @__PURE__ */ jsxs("div", {
947
+ "aria-label": "Conversation",
787
948
  className: "clivly-chat-widget__body",
788
949
  ref: listRef,
950
+ role: "log",
789
951
  children: [
790
952
  error ? /* @__PURE__ */ jsx("div", {
791
953
  className: "clivly-chat-widget__error",
@@ -796,10 +958,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
796
958
  hasSession: Boolean(session),
797
959
  messages
798
960
  }),
799
- session && showWaitingNotice ? /* @__PURE__ */ jsx("p", {
800
- className: "clivly-chat-widget__notice",
801
- role: "status",
802
- children: waitingNoticeText(session.contact?.email)
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
803
972
  }) : null
804
973
  ]
805
974
  }),
@@ -821,7 +990,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
821
990
  event.preventDefault();
822
991
  if (messageDraft.trim() && canSend) submitMessage();
823
992
  },
824
- placeholder: "Write your message",
993
+ placeholder: composerPlaceholder(topics.length > 0),
825
994
  value: messageDraft
826
995
  })]
827
996
  }), /* @__PURE__ */ jsxs("div", {
@@ -947,6 +1116,10 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
947
1116
  markRead: () => {
948
1117
  if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
949
1118
  },
1119
+ selectTopic: (event) => {
1120
+ if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
1121
+ socket.send(JSON.stringify(event));
1122
+ },
950
1123
  sendMessage: (message) => {
951
1124
  if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
952
1125
  socket.send(JSON.stringify(message));
package/dist/browser.cjs CHANGED
@@ -163,6 +163,13 @@ const WIDGET_STYLES = `
163
163
  .clivly-chat-widget textarea {
164
164
  font: inherit;
165
165
  }
166
+ /* The standalone embed does not load the app's shared stylesheet. */
167
+ @media (width < 48rem), (any-pointer: coarse) {
168
+ .clivly-chat-widget input,
169
+ .clivly-chat-widget textarea {
170
+ font-size: max(16px, 1em);
171
+ }
172
+ }
166
173
  .clivly-chat-widget__launcher {
167
174
  align-items: center;
168
175
  background: var(--clivly-chat-accent);
@@ -372,6 +379,47 @@ const WIDGET_STYLES = `
372
379
  margin: 0;
373
380
  padding: 0.625rem 0.75rem;
374
381
  }
382
+ /* Automated and AI messages are marked as such, never styled as a teammate. */
383
+ .clivly-chat-widget__message[data-automation] {
384
+ background: transparent;
385
+ border: 1px dashed var(--clivly-chat-border);
386
+ }
387
+ .clivly-chat-widget__badge {
388
+ border: 1px solid var(--clivly-chat-border);
389
+ border-radius: 999px;
390
+ font-size: 0.6875rem;
391
+ padding: 0 0.4rem;
392
+ }
393
+ .clivly-chat-widget__topics {
394
+ display: flex;
395
+ flex-wrap: wrap;
396
+ gap: 0.5rem;
397
+ margin: 0;
398
+ padding: 0;
399
+ list-style: none;
400
+ }
401
+ .clivly-chat-widget__topic {
402
+ background: var(--clivly-chat-panel);
403
+ border: 1px solid var(--clivly-chat-accent);
404
+ border-radius: 999px;
405
+ color: var(--clivly-chat-text);
406
+ cursor: pointer;
407
+ font-size: 0.875rem;
408
+ min-height: 2.25rem;
409
+ padding: 0.4rem 0.85rem;
410
+ }
411
+ .clivly-chat-widget__topic:hover,
412
+ .clivly-chat-widget__topic:focus-visible {
413
+ background: var(--clivly-chat-visitor);
414
+ }
415
+ .clivly-chat-widget__topic:focus-visible {
416
+ outline: 2px solid var(--clivly-chat-accent);
417
+ outline-offset: 2px;
418
+ }
419
+ .clivly-chat-widget__topic[disabled] {
420
+ cursor: not-allowed;
421
+ opacity: 0.55;
422
+ }
375
423
  .clivly-chat-widget__empty {
376
424
  color: var(--clivly-chat-muted);
377
425
  font-size: 0.875rem;
@@ -468,8 +516,15 @@ function useResolvedTheme(theme) {
468
516
  * protocol reads as a single unit, and so `ChatWidget` stays inside the
469
517
  * cognitive-complexity budget.
470
518
  */
471
- function applyServerEvent(event, { setError, setIsReady, setMessages }) {
519
+ function applyServerEvent(event, actions) {
520
+ const { setError, setIsReady, setMessages } = actions;
472
521
  switch (event.type) {
522
+ case "assistant.status":
523
+ actions.setAssistantThinking(event.status === "thinking");
524
+ return;
525
+ case "support.state":
526
+ actions.setSupportState(event.state);
527
+ return;
473
528
  case "conversation.snapshot":
474
529
  setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
475
530
  return;
@@ -490,14 +545,41 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
490
545
  default: return;
491
546
  }
492
547
  }
548
+ /**
549
+ * What a message from the support assistant is called. Automated
550
+ * acknowledgements, AI answers and people are never presented alike, so a
551
+ * visitor is never led to think a person has read their message.
552
+ */
553
+ const AUTOMATION_BADGES = {
554
+ ai: "AI assistant",
555
+ ai_clarify: "AI assistant",
556
+ greeting: "Automated",
557
+ handoff: "Automated",
558
+ topic: "Automated"
559
+ };
560
+ const AUTHOR_LABEL_SUFFIX = / · (?:Automated|AI assistant)$/;
561
+ function automationBadge(message) {
562
+ if (message.senderType !== "system" || !message.automation) return null;
563
+ return AUTOMATION_BADGES[message.automation] ?? "Automated";
564
+ }
493
565
  function MessageBubble({ message }) {
566
+ const badge = automationBadge(message);
567
+ const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
494
568
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
495
569
  className: "clivly-chat-widget__message",
570
+ "data-automation": badge ? message.automation : void 0,
496
571
  "data-sender": message.senderType,
497
572
  children: [
498
573
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
499
574
  className: "clivly-chat-widget__message-meta",
500
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: message.author ?? "Clivly" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })]
575
+ children: [
576
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: author || "Clivly" }),
577
+ badge ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
578
+ className: "clivly-chat-widget__badge",
579
+ children: badge
580
+ }) : null,
581
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatTimestamp(message.createdAt) })
582
+ ]
501
583
  }),
502
584
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: message.body }),
503
585
  message.delivery === "sent" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -540,6 +622,7 @@ function awaitingReplySince(messages) {
540
622
  for (let index = messages.length - 1; index >= 0; index--) {
541
623
  const message = messages[index];
542
624
  if (!message || message.delivery === "failed") continue;
625
+ if (message.senderType === "system" || message.automation === "topic_choice") continue;
543
626
  if (message.senderType !== "visitor") return null;
544
627
  return new Date(message.createdAt).getTime();
545
628
  }
@@ -593,6 +676,70 @@ function composerStatusText(hasTransport, isReady) {
593
676
  if (!hasTransport) return "Chat is unavailable";
594
677
  return isReady ? "Conversation ready" : "Connecting…";
595
678
  }
679
+ /**
680
+ * Guided options under the greeting. Native buttons in a labelled group, so
681
+ * they are reachable with Tab and operable with Enter/Space; free text stays
682
+ * available beside them the whole time.
683
+ */
684
+ function SupportTopics({ disabled, onSelect, topics }) {
685
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
686
+ "aria-label": "Suggested topics",
687
+ className: "clivly-chat-widget__topics",
688
+ children: topics.map((topic) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
689
+ className: "clivly-chat-widget__topic",
690
+ disabled,
691
+ onClick: () => {
692
+ onSelect(topic);
693
+ },
694
+ type: "button",
695
+ children: topic.label
696
+ }) }, topic.id))
697
+ });
698
+ }
699
+ /** Topics are offered only while automation is answering this conversation. */
700
+ function visibleTopics(session, state) {
701
+ return state === "automated" ? session?.support?.topics ?? [] : [];
702
+ }
703
+ function composerPlaceholder(hasTopics) {
704
+ return hasTopics ? "Or type your question" : "Write your message";
705
+ }
706
+ /**
707
+ * Hand one visitor message to the transport: a topic click as `topic.select`
708
+ * when the transport supports it (the server resolves the answer), otherwise
709
+ * as the label, typed. Throws what the transport throws.
710
+ */
711
+ function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
712
+ if (topic && transport.selectTopic) {
713
+ transport.selectTopic({
714
+ id: clientMessageId,
715
+ topicId: topic.id,
716
+ type: "topic.select"
717
+ });
718
+ return;
719
+ }
720
+ transport.sendMessage({
721
+ body,
722
+ id: clientMessageId,
723
+ type: "message.create"
724
+ });
725
+ }
726
+ /**
727
+ * One honest status line: the AI is working on an answer, or — once the
728
+ * visitor has waited — that the team has been told. Never both.
729
+ */
730
+ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
731
+ if (assistantThinking) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
732
+ className: "clivly-chat-widget__notice",
733
+ role: "status",
734
+ children: "The AI assistant is looking for an answer…"
735
+ });
736
+ if (!showWaitingNotice) return null;
737
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
738
+ className: "clivly-chat-widget__notice",
739
+ role: "status",
740
+ children: waitingNoticeText(email)
741
+ });
742
+ }
596
743
  function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
597
744
  const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
598
745
  const [error, setError] = (0, react.useState)(null);
@@ -601,6 +748,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
601
748
  const [messageDraft, setMessageDraft] = (0, react.useState)("");
602
749
  const [messages, setMessages] = (0, react.useState)([]);
603
750
  const [session, setSession] = (0, react.useState)(null);
751
+ const [supportState, setSupportState] = (0, react.useState)(null);
752
+ const [assistantThinking, setAssistantThinking] = (0, react.useState)(false);
604
753
  const [visitor, setVisitor] = (0, react.useState)({
605
754
  email: defaultVisitor?.email ?? "",
606
755
  name: defaultVisitor?.name ?? ""
@@ -631,15 +780,18 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
631
780
  handlers: {
632
781
  onClose: () => {
633
782
  setIsReady(false);
783
+ setAssistantThinking(false);
634
784
  },
635
785
  onError: (transportError) => {
636
786
  setError(transportError.message);
637
787
  },
638
788
  onEvent: (event) => {
639
789
  applyServerEvent(event, {
790
+ setAssistantThinking,
640
791
  setError,
641
792
  setIsReady,
642
- setMessages
793
+ setMessages,
794
+ setSupportState
643
795
  });
644
796
  },
645
797
  onOpen: () => {
@@ -681,6 +833,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
681
833
  widgetId
682
834
  });
683
835
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
836
+ setSupportState(nextSession.support?.state ?? null);
684
837
  setSession(nextSession);
685
838
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
686
839
  } catch (sessionError) {
@@ -713,41 +866,48 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
713
866
  session,
714
867
  startSession
715
868
  ]);
716
- const submitMessage = (event) => {
717
- event?.preventDefault();
718
- if (!(session && transportRef.current)) {
869
+ /**
870
+ * Show the visitor's message at once and hand it to the transport. A topic
871
+ * click goes as `topic.select` when the transport supports it, so the
872
+ * server resolves the answer; otherwise as the label, typed.
873
+ */
874
+ const sendVisitorMessage = (body, topic) => {
875
+ const transport = transportRef.current;
876
+ if (!(session && transport)) {
719
877
  setError("Chat is not connected yet.");
720
878
  return;
721
879
  }
722
- const body = messageDraft.trim();
723
- if (!body) return;
724
880
  const clientMessageId = generateId();
725
881
  const optimisticMessage = {
726
882
  author: sessionDisplayName(session),
883
+ automation: topic && transport.selectTopic ? "topic_choice" : null,
727
884
  body,
728
885
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
729
886
  id: clientMessageId,
730
887
  senderType: "visitor"
731
888
  };
732
889
  setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
733
- setMessageDraft("");
734
890
  setError(null);
735
891
  try {
736
- transportRef.current.sendMessage({
737
- body,
738
- id: clientMessageId,
739
- type: "message.create"
740
- });
892
+ dispatchVisitorMessage(transport, clientMessageId, body, topic);
741
893
  } catch (transportError) {
742
894
  const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
743
895
  setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
744
896
  setError(resolved.message);
745
897
  }
746
898
  };
899
+ const submitMessage = (event) => {
900
+ event?.preventDefault();
901
+ const body = messageDraft.trim();
902
+ if (!body) return;
903
+ setMessageDraft("");
904
+ sendVisitorMessage(body);
905
+ };
747
906
  const canSend = Boolean(createTransport) && isReady;
748
907
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
749
908
  const panelTitle = session?.widget.name ?? "Start a conversation";
750
909
  const sessionSummary = sessionSummaryText(session, greeting);
910
+ const topics = visibleTopics(session, supportState);
751
911
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
752
912
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
753
913
  "data-clivly-theme": resolvedTheme,
@@ -785,8 +945,10 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
785
945
  })]
786
946
  }),
787
947
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
948
+ "aria-label": "Conversation",
788
949
  className: "clivly-chat-widget__body",
789
950
  ref: listRef,
951
+ role: "log",
790
952
  children: [
791
953
  error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
792
954
  className: "clivly-chat-widget__error",
@@ -797,10 +959,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
797
959
  hasSession: Boolean(session),
798
960
  messages
799
961
  }),
800
- session && showWaitingNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
801
- className: "clivly-chat-widget__notice",
802
- role: "status",
803
- children: waitingNoticeText(session.contact?.email)
962
+ topics.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SupportTopics, {
963
+ disabled: !canSend,
964
+ onSelect: (topic) => {
965
+ sendVisitorMessage(topic.label, topic);
966
+ },
967
+ topics
968
+ }) : null,
969
+ session ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusNotice, {
970
+ assistantThinking,
971
+ email: session.contact?.email,
972
+ showWaitingNotice
804
973
  }) : null
805
974
  ]
806
975
  }),
@@ -822,7 +991,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
822
991
  event.preventDefault();
823
992
  if (messageDraft.trim() && canSend) submitMessage();
824
993
  },
825
- placeholder: "Write your message",
994
+ placeholder: composerPlaceholder(topics.length > 0),
826
995
  value: messageDraft
827
996
  })]
828
997
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -948,6 +1117,10 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
948
1117
  markRead: () => {
949
1118
  if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
950
1119
  },
1120
+ selectTopic: (event) => {
1121
+ if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
1122
+ socket.send(JSON.stringify(event));
1123
+ },
951
1124
  sendMessage: (message) => {
952
1125
  if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
953
1126
  socket.send(JSON.stringify(message));