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