@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.
@@ -34,11 +34,30 @@ interface WidgetSummary {
34
34
  }
35
35
  interface WidgetMessage {
36
36
  author: string | null;
37
+ /**
38
+ * Set on messages the support assistant wrote (`greeting`, `topic`,
39
+ * `handoff`, `ai`, `ai_clarify`) and on a visitor's topic click
40
+ * (`topic_choice`). Absent or null on everything a person typed, and on
41
+ * every message from a server that predates the assistant.
42
+ */
43
+ automation?: string | null;
37
44
  body: string;
38
45
  createdAt: string;
39
46
  id: string;
40
47
  senderType: WidgetSenderType;
41
48
  }
49
+ type WidgetSupportState = "automated" | "handoff" | "human";
50
+ interface WidgetSupportTopic {
51
+ action: "answer" | "ask" | "link" | "human";
52
+ id: string;
53
+ label: string;
54
+ }
55
+ /** Guided support for one conversation. Answers stay on the server. */
56
+ interface WidgetSupport {
57
+ mode: "human" | "guided" | "ai";
58
+ state: WidgetSupportState | null;
59
+ topics: WidgetSupportTopic[];
60
+ }
42
61
  interface WidgetSessionPayload {
43
62
  contact: WidgetContact | null;
44
63
  conversation: WidgetConversation;
@@ -46,6 +65,8 @@ interface WidgetSessionPayload {
46
65
  messages: WidgetMessage[];
47
66
  /** Absolute socket URL returned by Clivly's bootstrap. */
48
67
  socketUrl?: string;
68
+ /** Absent or null when the widget has no guided support configured. */
69
+ support?: WidgetSupport | null;
49
70
  token: string;
50
71
  visitorToken: string;
51
72
  widget: WidgetSummary;
@@ -65,6 +86,12 @@ interface WidgetClientCreateMessageEvent {
65
86
  id: string;
66
87
  type: "message.create";
67
88
  }
89
+ /** The visitor picked a guided topic (see `ChatTransportConnection.selectTopic`). */
90
+ interface WidgetClientTopicEvent {
91
+ id: string;
92
+ topicId: string;
93
+ type: "topic.select";
94
+ }
68
95
  interface WidgetSessionReadyEvent {
69
96
  conversationId: string;
70
97
  type: "session.ready";
@@ -83,11 +110,20 @@ interface WidgetMessageAckEvent {
83
110
  message: WidgetMessage;
84
111
  type: "message.ack";
85
112
  }
113
+ interface WidgetSupportStateEvent {
114
+ state: WidgetSupportState;
115
+ type: "support.state";
116
+ }
117
+ /** The assistant is preparing an answer (`thinking`) or has finished (`idle`). */
118
+ interface WidgetAssistantStatusEvent {
119
+ status: "thinking" | "idle";
120
+ type: "assistant.status";
121
+ }
86
122
  interface WidgetErrorEvent {
87
123
  message: string;
88
124
  type: "error";
89
125
  }
90
- type WidgetServerEvent = WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent;
126
+ type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
91
127
  //#endregion
92
128
  //#region src/transport.d.ts
93
129
  interface ChatTransportHandlers {
@@ -104,6 +140,11 @@ interface ChatTransportConnection {
104
140
  * without it still works, its visitors are just emailed replies they saw.
105
141
  */
106
142
  markRead?: () => void;
143
+ /**
144
+ * Send a guided-topic click. Optional: without it the widget sends the
145
+ * topic's label as an ordinary message, so a custom transport still works.
146
+ */
147
+ selectTopic?: (event: WidgetClientTopicEvent) => void;
107
148
  sendMessage: (event: WidgetClientCreateMessageEvent) => void;
108
149
  }
109
150
  interface CreateChatTransportOptions {
@@ -34,11 +34,30 @@ interface WidgetSummary {
34
34
  }
35
35
  interface WidgetMessage {
36
36
  author: string | null;
37
+ /**
38
+ * Set on messages the support assistant wrote (`greeting`, `topic`,
39
+ * `handoff`, `ai`, `ai_clarify`) and on a visitor's topic click
40
+ * (`topic_choice`). Absent or null on everything a person typed, and on
41
+ * every message from a server that predates the assistant.
42
+ */
43
+ automation?: string | null;
37
44
  body: string;
38
45
  createdAt: string;
39
46
  id: string;
40
47
  senderType: WidgetSenderType;
41
48
  }
49
+ type WidgetSupportState = "automated" | "handoff" | "human";
50
+ interface WidgetSupportTopic {
51
+ action: "answer" | "ask" | "link" | "human";
52
+ id: string;
53
+ label: string;
54
+ }
55
+ /** Guided support for one conversation. Answers stay on the server. */
56
+ interface WidgetSupport {
57
+ mode: "human" | "guided" | "ai";
58
+ state: WidgetSupportState | null;
59
+ topics: WidgetSupportTopic[];
60
+ }
42
61
  interface WidgetSessionPayload {
43
62
  contact: WidgetContact | null;
44
63
  conversation: WidgetConversation;
@@ -46,6 +65,8 @@ interface WidgetSessionPayload {
46
65
  messages: WidgetMessage[];
47
66
  /** Absolute socket URL returned by Clivly's bootstrap. */
48
67
  socketUrl?: string;
68
+ /** Absent or null when the widget has no guided support configured. */
69
+ support?: WidgetSupport | null;
49
70
  token: string;
50
71
  visitorToken: string;
51
72
  widget: WidgetSummary;
@@ -65,6 +86,12 @@ interface WidgetClientCreateMessageEvent {
65
86
  id: string;
66
87
  type: "message.create";
67
88
  }
89
+ /** The visitor picked a guided topic (see `ChatTransportConnection.selectTopic`). */
90
+ interface WidgetClientTopicEvent {
91
+ id: string;
92
+ topicId: string;
93
+ type: "topic.select";
94
+ }
68
95
  interface WidgetSessionReadyEvent {
69
96
  conversationId: string;
70
97
  type: "session.ready";
@@ -83,11 +110,20 @@ interface WidgetMessageAckEvent {
83
110
  message: WidgetMessage;
84
111
  type: "message.ack";
85
112
  }
113
+ interface WidgetSupportStateEvent {
114
+ state: WidgetSupportState;
115
+ type: "support.state";
116
+ }
117
+ /** The assistant is preparing an answer (`thinking`) or has finished (`idle`). */
118
+ interface WidgetAssistantStatusEvent {
119
+ status: "thinking" | "idle";
120
+ type: "assistant.status";
121
+ }
86
122
  interface WidgetErrorEvent {
87
123
  message: string;
88
124
  type: "error";
89
125
  }
90
- type WidgetServerEvent = WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent;
126
+ type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
91
127
  //#endregion
92
128
  //#region src/transport.d.ts
93
129
  interface ChatTransportHandlers {
@@ -104,6 +140,11 @@ interface ChatTransportConnection {
104
140
  * without it still works, its visitors are just emailed replies they saw.
105
141
  */
106
142
  markRead?: () => void;
143
+ /**
144
+ * Send a guided-topic click. Optional: without it the widget sends the
145
+ * topic's label as an ordinary message, so a custom transport still works.
146
+ */
147
+ selectTopic?: (event: WidgetClientTopicEvent) => void;
107
148
  sendMessage: (event: WidgetClientCreateMessageEvent) => void;
108
149
  }
109
150
  interface CreateChatTransportOptions {
package/dist/browser.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);
@@ -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));