@clivly/chat-widget 0.14.1 → 0.16.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/browser.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRoot } from "react-dom/client";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/theme.ts
5
5
  const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
@@ -162,16 +162,102 @@ 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
+ }
172
+ /* Floating placements. The insets keep the launcher off a phone's home
173
+ indicator and rounded corners; --clivly-chat-z-index lets a host slot it
174
+ beneath its own dialogs. */
175
+ .clivly-chat-widget[data-clivly-placement="bottom-right"],
176
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
177
+ bottom: max(1.25rem, env(safe-area-inset-bottom));
178
+ position: fixed;
179
+ z-index: var(--clivly-chat-z-index, 2147483000);
180
+ }
181
+ .clivly-chat-widget[data-clivly-placement="bottom-right"] {
182
+ right: max(1.25rem, env(safe-area-inset-right));
183
+ }
184
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] {
185
+ left: max(1.25rem, env(safe-area-inset-left));
186
+ }
165
187
  .clivly-chat-widget__launcher {
166
188
  align-items: center;
167
189
  background: var(--clivly-chat-accent);
168
190
  border: 0;
169
191
  border-radius: 999px;
192
+ box-shadow:
193
+ 0 2px 6px rgba(0, 0, 0, 0.18),
194
+ 0 10px 28px -10px var(--clivly-chat-accent);
170
195
  color: var(--clivly-chat-accent-contrast);
171
196
  cursor: pointer;
172
197
  display: inline-flex;
198
+ font-weight: 500;
173
199
  gap: 0.5rem;
174
- padding: 0.85rem 1.1rem;
200
+ justify-content: center;
201
+ min-height: 3rem;
202
+ padding: 0 1.15rem;
203
+ transition: transform 150ms ease-out, filter 150ms ease-out;
204
+ }
205
+ .clivly-chat-widget__launcher:hover {
206
+ filter: brightness(1.06);
207
+ transform: translateY(-1px);
208
+ }
209
+ .clivly-chat-widget__launcher:active {
210
+ transform: scale(0.96);
211
+ }
212
+ .clivly-chat-widget__launcher:focus-visible {
213
+ outline: 2px solid var(--clivly-chat-accent);
214
+ outline-offset: 3px;
215
+ }
216
+ .clivly-chat-widget__launcher-icon {
217
+ flex-shrink: 0;
218
+ height: 1.25rem;
219
+ width: 1.25rem;
220
+ }
221
+ /* Icon-only: a 48px circle — a comfortable touch target that does not cover
222
+ the host's pagination, toolbars or form controls the way a labelled pill
223
+ does on a phone. The label is clipped, not removed, so it stays the
224
+ button's accessible name. */
225
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
226
+ height: 3rem;
227
+ padding: 0;
228
+ width: 3rem;
229
+ }
230
+ .clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
231
+ clip-path: inset(50%);
232
+ height: 1px;
233
+ overflow: hidden;
234
+ position: absolute;
235
+ white-space: nowrap;
236
+ width: 1px;
237
+ }
238
+ @media (width < 48rem) {
239
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
240
+ height: 3rem;
241
+ padding: 0;
242
+ width: 3rem;
243
+ }
244
+ .clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
245
+ clip-path: inset(50%);
246
+ height: 1px;
247
+ overflow: hidden;
248
+ position: absolute;
249
+ white-space: nowrap;
250
+ width: 1px;
251
+ }
252
+ }
253
+ @media (prefers-reduced-motion: reduce) {
254
+ .clivly-chat-widget__launcher {
255
+ transition: none;
256
+ }
257
+ .clivly-chat-widget__launcher:hover,
258
+ .clivly-chat-widget__launcher:active {
259
+ transform: none;
260
+ }
175
261
  }
176
262
  .clivly-chat-widget__panel {
177
263
  background: var(--clivly-chat-panel);
@@ -187,6 +273,19 @@ const WIDGET_STYLES = `
187
273
  bottom: 4.5rem;
188
274
  width: min(24rem, calc(100vw - 2rem));
189
275
  }
276
+ /* Opens toward the page from whichever corner the launcher is in. */
277
+ .clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
278
+ left: 0;
279
+ right: auto;
280
+ }
281
+ /* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
282
+ edge could slide under it. The 48px launcher sits lower than the pill. */
283
+ @media (width < 48rem) {
284
+ .clivly-chat-widget__panel {
285
+ bottom: 4rem;
286
+ height: min(44rem, calc(100dvh - 6.5rem));
287
+ }
288
+ }
190
289
  .clivly-chat-widget__header {
191
290
  border-bottom: 1px solid var(--clivly-chat-border);
192
291
  display: flex;
@@ -371,6 +470,47 @@ const WIDGET_STYLES = `
371
470
  margin: 0;
372
471
  padding: 0.625rem 0.75rem;
373
472
  }
473
+ /* Automated and AI messages are marked as such, never styled as a teammate. */
474
+ .clivly-chat-widget__message[data-automation] {
475
+ background: transparent;
476
+ border: 1px dashed var(--clivly-chat-border);
477
+ }
478
+ .clivly-chat-widget__badge {
479
+ border: 1px solid var(--clivly-chat-border);
480
+ border-radius: 999px;
481
+ font-size: 0.6875rem;
482
+ padding: 0 0.4rem;
483
+ }
484
+ .clivly-chat-widget__topics {
485
+ display: flex;
486
+ flex-wrap: wrap;
487
+ gap: 0.5rem;
488
+ margin: 0;
489
+ padding: 0;
490
+ list-style: none;
491
+ }
492
+ .clivly-chat-widget__topic {
493
+ background: var(--clivly-chat-panel);
494
+ border: 1px solid var(--clivly-chat-accent);
495
+ border-radius: 999px;
496
+ color: var(--clivly-chat-text);
497
+ cursor: pointer;
498
+ font-size: 0.875rem;
499
+ min-height: 2.25rem;
500
+ padding: 0.4rem 0.85rem;
501
+ }
502
+ .clivly-chat-widget__topic:hover,
503
+ .clivly-chat-widget__topic:focus-visible {
504
+ background: var(--clivly-chat-visitor);
505
+ }
506
+ .clivly-chat-widget__topic:focus-visible {
507
+ outline: 2px solid var(--clivly-chat-accent);
508
+ outline-offset: 2px;
509
+ }
510
+ .clivly-chat-widget__topic[disabled] {
511
+ cursor: not-allowed;
512
+ opacity: 0.55;
513
+ }
374
514
  .clivly-chat-widget__empty {
375
515
  color: var(--clivly-chat-muted);
376
516
  font-size: 0.875rem;
@@ -467,8 +607,15 @@ function useResolvedTheme(theme) {
467
607
  * protocol reads as a single unit, and so `ChatWidget` stays inside the
468
608
  * cognitive-complexity budget.
469
609
  */
470
- function applyServerEvent(event, { setError, setIsReady, setMessages }) {
610
+ function applyServerEvent(event, actions) {
611
+ const { setError, setIsReady, setMessages } = actions;
471
612
  switch (event.type) {
613
+ case "assistant.status":
614
+ actions.setAssistantThinking(event.status === "thinking");
615
+ return;
616
+ case "support.state":
617
+ actions.setSupportState(event.state);
618
+ return;
472
619
  case "conversation.snapshot":
473
620
  setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
474
621
  return;
@@ -489,14 +636,80 @@ function applyServerEvent(event, { setError, setIsReady, setMessages }) {
489
636
  default: return;
490
637
  }
491
638
  }
639
+ /**
640
+ * What a message from the support assistant is called. Automated
641
+ * acknowledgements, AI answers and people are never presented alike, so a
642
+ * visitor is never led to think a person has read their message.
643
+ */
644
+ const AUTOMATION_BADGES = {
645
+ ai: "AI assistant",
646
+ ai_clarify: "AI assistant",
647
+ greeting: "Automated",
648
+ handoff: "Automated",
649
+ topic: "Automated"
650
+ };
651
+ const AUTHOR_LABEL_SUFFIX = / · (?:Automated|AI assistant)$/;
652
+ function automationBadge(message) {
653
+ if (message.senderType !== "system" || !message.automation) return null;
654
+ return AUTOMATION_BADGES[message.automation] ?? "Automated";
655
+ }
656
+ /**
657
+ * The button that opens and closes the panel. Its size and shape come from the
658
+ * root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
659
+ * accessible name, and `aria-controls` names the panel only while it exists.
660
+ */
661
+ function Launcher({ label, onToggle, open, panelId }) {
662
+ return /* @__PURE__ */ jsxs("button", {
663
+ "aria-controls": open ? panelId : void 0,
664
+ "aria-expanded": open,
665
+ className: "clivly-chat-widget__launcher",
666
+ onClick: onToggle,
667
+ type: "button",
668
+ children: [/* @__PURE__ */ jsx(LauncherIcon, { open }), /* @__PURE__ */ jsx("span", {
669
+ className: "clivly-chat-widget__launcher-label",
670
+ children: label
671
+ })]
672
+ });
673
+ }
674
+ /**
675
+ * A chat bubble while closed, a cross while open — so the icon-only launcher
676
+ * still tells a sighted visitor what tapping it will do. Decorative: the
677
+ * label and `aria-expanded` carry the meaning for assistive technology.
678
+ * Inline SVG because the script-tag build renders inside a shadow root with no
679
+ * icon font or stylesheet of the host's to borrow.
680
+ */
681
+ function LauncherIcon({ open }) {
682
+ return /* @__PURE__ */ jsx("svg", {
683
+ "aria-hidden": "true",
684
+ className: "clivly-chat-widget__launcher-icon",
685
+ fill: "none",
686
+ focusable: "false",
687
+ stroke: "currentColor",
688
+ strokeLinecap: "round",
689
+ strokeLinejoin: "round",
690
+ strokeWidth: 2,
691
+ viewBox: "0 0 24 24",
692
+ children: open ? /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
693
+ });
694
+ }
492
695
  function MessageBubble({ message }) {
696
+ const badge = automationBadge(message);
697
+ const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
493
698
  return /* @__PURE__ */ jsxs("article", {
494
699
  className: "clivly-chat-widget__message",
700
+ "data-automation": badge ? message.automation : void 0,
495
701
  "data-sender": message.senderType,
496
702
  children: [
497
703
  /* @__PURE__ */ jsxs("div", {
498
704
  className: "clivly-chat-widget__message-meta",
499
- children: [/* @__PURE__ */ jsx("span", { children: message.author ?? "Clivly" }), /* @__PURE__ */ jsx("span", { children: formatTimestamp(message.createdAt) })]
705
+ children: [
706
+ /* @__PURE__ */ jsx("span", { children: author || "Clivly" }),
707
+ badge ? /* @__PURE__ */ jsx("span", {
708
+ className: "clivly-chat-widget__badge",
709
+ children: badge
710
+ }) : null,
711
+ /* @__PURE__ */ jsx("span", { children: formatTimestamp(message.createdAt) })
712
+ ]
500
713
  }),
501
714
  /* @__PURE__ */ jsx("div", { children: message.body }),
502
715
  message.delivery === "sent" ? null : /* @__PURE__ */ jsx("div", {
@@ -539,6 +752,7 @@ function awaitingReplySince(messages) {
539
752
  for (let index = messages.length - 1; index >= 0; index--) {
540
753
  const message = messages[index];
541
754
  if (!message || message.delivery === "failed") continue;
755
+ if (message.senderType === "system" || message.automation === "topic_choice") continue;
542
756
  if (message.senderType !== "visitor") return null;
543
757
  return new Date(message.createdAt).getTime();
544
758
  }
@@ -588,60 +802,148 @@ function isCompleteVisitor(visitor) {
588
802
  * the visitor is told to wait for something that will never arrive. The third
589
803
  * string keeps the status honest in all three states.
590
804
  */
591
- function composerStatusText(hasTransport, isReady) {
805
+ function composerStatusText(hasTransport, isReady, reconnecting) {
592
806
  if (!hasTransport) return "Chat is unavailable";
593
- return isReady ? "Conversation ready" : "Connecting…";
807
+ if (isReady) return "Conversation ready";
808
+ return reconnecting ? "Reconnecting…" : "Connecting…";
594
809
  }
595
- function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
596
- const [isOpen, setIsOpen] = useState(initiallyOpen);
597
- const [error, setError] = useState(null);
598
- const [isConnecting, setIsConnecting] = useState(false);
599
- const [isReady, setIsReady] = useState(false);
600
- const [messageDraft, setMessageDraft] = useState("");
601
- const [messages, setMessages] = useState([]);
602
- const [session, setSession] = useState(null);
603
- const [visitor, setVisitor] = useState({
604
- email: defaultVisitor?.email ?? "",
605
- name: defaultVisitor?.name ?? ""
810
+ /**
811
+ * Guided options under the greeting. Native buttons in a labelled group, so
812
+ * they are reachable with Tab and operable with Enter/Space; free text stays
813
+ * available beside them the whole time.
814
+ */
815
+ function SupportTopics({ disabled, onSelect, topics }) {
816
+ return /* @__PURE__ */ jsx("ul", {
817
+ "aria-label": "Suggested topics",
818
+ className: "clivly-chat-widget__topics",
819
+ children: topics.map((topic) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
820
+ className: "clivly-chat-widget__topic",
821
+ disabled,
822
+ onClick: () => {
823
+ onSelect(topic);
824
+ },
825
+ type: "button",
826
+ children: topic.label
827
+ }) }, topic.id))
606
828
  });
607
- const transportRef = useRef(null);
608
- const listRef = useRef(null);
609
- useEffect(() => {
610
- if (createTransport) return;
611
- 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");
612
- }, []);
613
- const showWaitingNotice = useWaitingNotice(messages);
614
- const themeStyle = useThemeStyle(session, style);
615
- const resolvedTheme = useResolvedTheme(theme);
616
- useEffect(() => () => {
617
- transportRef.current?.close();
618
- transportRef.current = null;
619
- }, []);
829
+ }
830
+ /** Topics are offered only while automation is answering this conversation. */
831
+ function visibleTopics(session, state) {
832
+ return state === "automated" ? session?.support?.topics ?? [] : [];
833
+ }
834
+ function composerPlaceholder(hasTopics) {
835
+ return hasTopics ? "Or type your question" : "Write your message";
836
+ }
837
+ /**
838
+ * Hand one visitor message to the transport: a topic click as `topic.select`
839
+ * when the transport supports it (the server resolves the answer), otherwise
840
+ * as the label, typed. Throws what the transport throws.
841
+ */
842
+ function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
843
+ if (topic && transport.selectTopic) {
844
+ transport.selectTopic({
845
+ id: clientMessageId,
846
+ topicId: topic.id,
847
+ type: "topic.select"
848
+ });
849
+ return;
850
+ }
851
+ transport.sendMessage({
852
+ body,
853
+ id: clientMessageId,
854
+ type: "message.create"
855
+ });
856
+ }
857
+ /**
858
+ * One honest status line: the AI is working on an answer, or — once the
859
+ * visitor has waited — that the team has been told. Never both.
860
+ */
861
+ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
862
+ if (assistantThinking) return /* @__PURE__ */ jsx("p", {
863
+ className: "clivly-chat-widget__notice",
864
+ role: "status",
865
+ children: "The AI assistant is looking for an answer…"
866
+ });
867
+ if (!showWaitingNotice) return null;
868
+ return /* @__PURE__ */ jsx("p", {
869
+ className: "clivly-chat-widget__notice",
870
+ role: "status",
871
+ children: waitingNoticeText(email)
872
+ });
873
+ }
874
+ const RECONNECT_BASE_MS = 1e3;
875
+ const RECONNECT_MAX_MS = 3e4;
876
+ /**
877
+ * How long to wait before reconnect attempt `attempt` (0-based): doubling from
878
+ * one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
879
+ * by the same server restart doesn't reconnect in lockstep.
880
+ */
881
+ function reconnectDelayMs(attempt, random = Math.random) {
882
+ const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
883
+ return Math.round(base * (.75 + random() * .5));
884
+ }
885
+ /**
886
+ * Keep the socket for `session` open, and reconnect when it drops — a laptop
887
+ * waking, a network change, a server deploy — instead of leaving the visitor at
888
+ * "Connecting…" until they reload. A reconnect refreshes the session (same
889
+ * conversation, fresh token) and the server's snapshot on connect brings back
890
+ * anything that arrived meanwhile. Returns whether a reconnect is pending.
891
+ */
892
+ function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
893
+ const [reconnecting, setReconnecting] = useState(false);
894
+ const attemptRef = useRef(0);
895
+ const refreshRef = useRef(refreshSession);
896
+ const actionsRef = useRef(actions);
620
897
  useEffect(() => {
621
- if (!listRef.current) return;
622
- listRef.current.scrollTop = listRef.current.scrollHeight;
623
- }, [messages]);
898
+ refreshRef.current = refreshSession;
899
+ actionsRef.current = actions;
900
+ });
624
901
  useEffect(() => {
625
902
  transportRef.current?.close();
626
903
  transportRef.current = null;
627
904
  setIsReady(false);
628
905
  if (!(session && createTransport)) return;
906
+ let disposed = false;
907
+ let timer;
908
+ const retry = () => {
909
+ timer = void 0;
910
+ refreshRef.current(session).catch(() => {
911
+ scheduleReconnect();
912
+ });
913
+ };
914
+ const scheduleReconnect = () => {
915
+ if (disposed || timer !== void 0) return;
916
+ setReconnecting(true);
917
+ timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
918
+ attemptRef.current += 1;
919
+ };
920
+ const retryNow = () => {
921
+ if (timer !== void 0) {
922
+ clearTimeout(timer);
923
+ retry();
924
+ }
925
+ };
926
+ window.addEventListener("online", retryNow);
629
927
  const connection = createTransport({
630
928
  handlers: {
631
929
  onClose: () => {
632
930
  setIsReady(false);
931
+ actionsRef.current.setAssistantThinking(false);
932
+ scheduleReconnect();
633
933
  },
634
934
  onError: (transportError) => {
635
- setError(transportError.message);
935
+ if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
636
936
  },
637
937
  onEvent: (event) => {
638
938
  applyServerEvent(event, {
639
- setError,
640
- setIsReady,
641
- setMessages
939
+ ...actionsRef.current,
940
+ setIsReady
642
941
  });
643
942
  },
644
943
  onOpen: () => {
944
+ if (attemptRef.current > 0) actionsRef.current.setError(null);
945
+ attemptRef.current = 0;
946
+ setReconnecting(false);
645
947
  setIsReady(true);
646
948
  }
647
949
  },
@@ -649,10 +951,72 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
649
951
  });
650
952
  transportRef.current = connection;
651
953
  return () => {
954
+ disposed = true;
955
+ if (timer !== void 0) clearTimeout(timer);
956
+ window.removeEventListener("online", retryNow);
652
957
  connection.close();
653
958
  if (transportRef.current === connection) transportRef.current = null;
654
959
  };
655
960
  }, [createTransport, session]);
961
+ return reconnecting;
962
+ }
963
+ function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
964
+ const [isOpen, setIsOpen] = useState(initiallyOpen);
965
+ const panelId = useId();
966
+ const [error, setError] = useState(null);
967
+ const [isConnecting, setIsConnecting] = useState(false);
968
+ const [isReady, setIsReady] = useState(false);
969
+ const [messageDraft, setMessageDraft] = useState("");
970
+ const [messages, setMessages] = useState([]);
971
+ const [session, setSession] = useState(null);
972
+ const [supportState, setSupportState] = useState(null);
973
+ const [assistantThinking, setAssistantThinking] = useState(false);
974
+ const [visitor, setVisitor] = useState({
975
+ email: defaultVisitor?.email ?? "",
976
+ name: defaultVisitor?.name ?? ""
977
+ });
978
+ const transportRef = useRef(null);
979
+ const listRef = useRef(null);
980
+ const identityRef = useRef(null);
981
+ useEffect(() => {
982
+ if (createTransport) return;
983
+ 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");
984
+ }, []);
985
+ const showWaitingNotice = useWaitingNotice(messages);
986
+ const themeStyle = useThemeStyle(session, style);
987
+ const resolvedTheme = useResolvedTheme(theme);
988
+ useEffect(() => () => {
989
+ transportRef.current?.close();
990
+ transportRef.current = null;
991
+ }, []);
992
+ useEffect(() => {
993
+ if (!listRef.current) return;
994
+ listRef.current.scrollTop = listRef.current.scrollHeight;
995
+ }, [messages]);
996
+ const reconnecting = useLiveConnection({
997
+ actions: {
998
+ setAssistantThinking,
999
+ setError,
1000
+ setMessages,
1001
+ setSupportState
1002
+ },
1003
+ createTransport,
1004
+ refreshSession: async (current) => {
1005
+ const identity = identityRef.current;
1006
+ if (!identity) throw new Error("No visitor to reconnect as.");
1007
+ const next = await getSession({
1008
+ visitor: identity,
1009
+ visitorToken: current.visitorToken,
1010
+ widgetId
1011
+ });
1012
+ storage?.setItem(createStorageKey(widgetId), next.visitorToken);
1013
+ setSupportState(next.support?.state ?? null);
1014
+ setSession(next);
1015
+ },
1016
+ session,
1017
+ setIsReady,
1018
+ transportRef
1019
+ });
656
1020
  useEffect(() => {
657
1021
  if (!(isOpen && isReady)) return;
658
1022
  const markReadIfVisible = () => {
@@ -680,6 +1044,8 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
680
1044
  widgetId
681
1045
  });
682
1046
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1047
+ identityRef.current = identity;
1048
+ setSupportState(nextSession.support?.state ?? null);
683
1049
  setSession(nextSession);
684
1050
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
685
1051
  } catch (sessionError) {
@@ -712,58 +1078,68 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
712
1078
  session,
713
1079
  startSession
714
1080
  ]);
715
- const submitMessage = (event) => {
716
- event?.preventDefault();
717
- if (!(session && transportRef.current)) {
1081
+ /**
1082
+ * Show the visitor's message at once and hand it to the transport. A topic
1083
+ * click goes as `topic.select` when the transport supports it, so the
1084
+ * server resolves the answer; otherwise as the label, typed.
1085
+ */
1086
+ const sendVisitorMessage = (body, topic) => {
1087
+ const transport = transportRef.current;
1088
+ if (!(session && transport)) {
718
1089
  setError("Chat is not connected yet.");
719
1090
  return;
720
1091
  }
721
- const body = messageDraft.trim();
722
- if (!body) return;
723
1092
  const clientMessageId = generateId();
724
1093
  const optimisticMessage = {
725
1094
  author: sessionDisplayName(session),
1095
+ automation: topic && transport.selectTopic ? "topic_choice" : null,
726
1096
  body,
727
1097
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
728
1098
  id: clientMessageId,
729
1099
  senderType: "visitor"
730
1100
  };
731
1101
  setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
732
- setMessageDraft("");
733
1102
  setError(null);
734
1103
  try {
735
- transportRef.current.sendMessage({
736
- body,
737
- id: clientMessageId,
738
- type: "message.create"
739
- });
1104
+ dispatchVisitorMessage(transport, clientMessageId, body, topic);
740
1105
  } catch (transportError) {
741
1106
  const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
742
1107
  setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
743
1108
  setError(resolved.message);
744
1109
  }
745
1110
  };
1111
+ const submitMessage = (event) => {
1112
+ event?.preventDefault();
1113
+ const body = messageDraft.trim();
1114
+ if (!body) return;
1115
+ setMessageDraft("");
1116
+ sendVisitorMessage(body);
1117
+ };
746
1118
  const canSend = Boolean(createTransport) && isReady;
747
1119
  const greeting = session?.widget.config.greeting ?? "Tell us a little about yourself and we’ll pick up the conversation from there.";
748
1120
  const panelTitle = session?.widget.name ?? "Start a conversation";
749
1121
  const sessionSummary = sessionSummaryText(session, greeting);
1122
+ const topics = visibleTopics(session, supportState);
750
1123
  return /* @__PURE__ */ jsxs("div", {
751
1124
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1125
+ "data-clivly-launcher": launcher,
1126
+ "data-clivly-placement": placement,
752
1127
  "data-clivly-theme": resolvedTheme,
753
1128
  style: themeStyle,
754
1129
  children: [
755
1130
  /* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
756
- /* @__PURE__ */ jsx("button", {
757
- className: "clivly-chat-widget__launcher",
758
- onClick: () => {
1131
+ /* @__PURE__ */ jsx(Launcher, {
1132
+ label: launcherLabel,
1133
+ onToggle: () => {
759
1134
  setIsOpen((open) => !open);
760
1135
  },
761
- type: "button",
762
- children: /* @__PURE__ */ jsx("span", { children: launcherLabel })
1136
+ open: isOpen,
1137
+ panelId
763
1138
  }),
764
1139
  isOpen ? /* @__PURE__ */ jsxs("section", {
765
1140
  "aria-label": panelTitle,
766
1141
  className: "clivly-chat-widget__panel",
1142
+ id: panelId,
767
1143
  children: [
768
1144
  /* @__PURE__ */ jsxs("header", {
769
1145
  className: "clivly-chat-widget__header",
@@ -784,8 +1160,10 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
784
1160
  })]
785
1161
  }),
786
1162
  /* @__PURE__ */ jsxs("div", {
1163
+ "aria-label": "Conversation",
787
1164
  className: "clivly-chat-widget__body",
788
1165
  ref: listRef,
1166
+ role: "log",
789
1167
  children: [
790
1168
  error ? /* @__PURE__ */ jsx("div", {
791
1169
  className: "clivly-chat-widget__error",
@@ -796,10 +1174,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
796
1174
  hasSession: Boolean(session),
797
1175
  messages
798
1176
  }),
799
- session && showWaitingNotice ? /* @__PURE__ */ jsx("p", {
800
- className: "clivly-chat-widget__notice",
801
- role: "status",
802
- children: waitingNoticeText(session.contact?.email)
1177
+ topics.length > 0 ? /* @__PURE__ */ jsx(SupportTopics, {
1178
+ disabled: !canSend,
1179
+ onSelect: (topic) => {
1180
+ sendVisitorMessage(topic.label, topic);
1181
+ },
1182
+ topics
1183
+ }) : null,
1184
+ session ? /* @__PURE__ */ jsx(StatusNotice, {
1185
+ assistantThinking,
1186
+ email: session.contact?.email,
1187
+ showWaitingNotice
803
1188
  }) : null
804
1189
  ]
805
1190
  }),
@@ -821,14 +1206,15 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
821
1206
  event.preventDefault();
822
1207
  if (messageDraft.trim() && canSend) submitMessage();
823
1208
  },
824
- placeholder: "Write your message",
1209
+ placeholder: composerPlaceholder(topics.length > 0),
825
1210
  value: messageDraft
826
1211
  })]
827
1212
  }), /* @__PURE__ */ jsxs("div", {
828
1213
  className: "clivly-chat-widget__actions",
829
1214
  children: [/* @__PURE__ */ jsx("span", {
1215
+ "aria-live": "polite",
830
1216
  className: "clivly-chat-widget__subtitle",
831
- children: composerStatusText(Boolean(createTransport), isReady)
1217
+ children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
832
1218
  }), /* @__PURE__ */ jsx("button", {
833
1219
  className: "clivly-chat-widget__button",
834
1220
  disabled: !(messageDraft.trim() && canSend),
@@ -947,6 +1333,10 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
947
1333
  markRead: () => {
948
1334
  if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
949
1335
  },
1336
+ selectTopic: (event) => {
1337
+ if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
1338
+ socket.send(JSON.stringify(event));
1339
+ },
950
1340
  sendMessage: (message) => {
951
1341
  if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
952
1342
  socket.send(JSON.stringify(message));
@@ -973,6 +1363,26 @@ function createClivlyTransport(options = {}) {
973
1363
  });
974
1364
  }
975
1365
  //#endregion
1366
+ //#region src/layout.ts
1367
+ const PLACEMENTS = [
1368
+ "bottom-left",
1369
+ "bottom-right",
1370
+ "inline"
1371
+ ];
1372
+ const LAUNCHERS = [
1373
+ "auto",
1374
+ "icon",
1375
+ "pill"
1376
+ ];
1377
+ /** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
1378
+ function parsePlacement(value) {
1379
+ return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
1380
+ }
1381
+ /** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
1382
+ function parseLauncher(value) {
1383
+ return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1384
+ }
1385
+ //#endregion
976
1386
  //#region src/session.ts
977
1387
  function isWidgetSessionPayload(value) {
978
1388
  return typeof value === "object" && value !== null;
@@ -1070,6 +1480,8 @@ function mountChatWidgetScript$1(script = document.currentScript) {
1070
1480
  const { root, setTheme } = createWidgetMount({
1071
1481
  container,
1072
1482
  defaultVisitor: readScriptVisitor(script.dataset),
1483
+ launcher: parseLauncher(script.dataset.launcher),
1484
+ placement: parsePlacement(script.dataset.placement),
1073
1485
  sessionUrl,
1074
1486
  socketUrlTemplate: script.dataset.socketUrlTemplate,
1075
1487
  theme: parseTheme(script.dataset.theme),