@clivly/chat-widget 0.15.0 → 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.
@@ -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)";
@@ -169,16 +169,95 @@ const WIDGET_STYLES = `
169
169
  font-size: max(16px, 1em);
170
170
  }
171
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
+ }
172
187
  .clivly-chat-widget__launcher {
173
188
  align-items: center;
174
189
  background: var(--clivly-chat-accent);
175
190
  border: 0;
176
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);
177
195
  color: var(--clivly-chat-accent-contrast);
178
196
  cursor: pointer;
179
197
  display: inline-flex;
198
+ font-weight: 500;
180
199
  gap: 0.5rem;
181
- 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
+ }
182
261
  }
183
262
  .clivly-chat-widget__panel {
184
263
  background: var(--clivly-chat-panel);
@@ -194,6 +273,19 @@ const WIDGET_STYLES = `
194
273
  bottom: 4.5rem;
195
274
  width: min(24rem, calc(100vw - 2rem));
196
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
+ }
197
289
  .clivly-chat-widget__header {
198
290
  border-bottom: 1px solid var(--clivly-chat-border);
199
291
  display: flex;
@@ -561,6 +653,45 @@ function automationBadge(message) {
561
653
  if (message.senderType !== "system" || !message.automation) return null;
562
654
  return AUTOMATION_BADGES[message.automation] ?? "Automated";
563
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
+ }
564
695
  function MessageBubble({ message }) {
565
696
  const badge = automationBadge(message);
566
697
  const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
@@ -671,9 +802,10 @@ function isCompleteVisitor(visitor) {
671
802
  * the visitor is told to wait for something that will never arrive. The third
672
803
  * string keeps the status honest in all three states.
673
804
  */
674
- function composerStatusText(hasTransport, isReady) {
805
+ function composerStatusText(hasTransport, isReady, reconnecting) {
675
806
  if (!hasTransport) return "Chat is unavailable";
676
- return isReady ? "Conversation ready" : "Connecting…";
807
+ if (isReady) return "Conversation ready";
808
+ return reconnecting ? "Reconnecting…" : "Connecting…";
677
809
  }
678
810
  /**
679
811
  * Guided options under the greeting. Native buttons in a labelled group, so
@@ -739,8 +871,98 @@ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
739
871
  children: waitingNoticeText(email)
740
872
  });
741
873
  }
742
- function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcherLabel = "Chat with us", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
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);
897
+ useEffect(() => {
898
+ refreshRef.current = refreshSession;
899
+ actionsRef.current = actions;
900
+ });
901
+ useEffect(() => {
902
+ transportRef.current?.close();
903
+ transportRef.current = null;
904
+ setIsReady(false);
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);
927
+ const connection = createTransport({
928
+ handlers: {
929
+ onClose: () => {
930
+ setIsReady(false);
931
+ actionsRef.current.setAssistantThinking(false);
932
+ scheduleReconnect();
933
+ },
934
+ onError: (transportError) => {
935
+ if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
936
+ },
937
+ onEvent: (event) => {
938
+ applyServerEvent(event, {
939
+ ...actionsRef.current,
940
+ setIsReady
941
+ });
942
+ },
943
+ onOpen: () => {
944
+ if (attemptRef.current > 0) actionsRef.current.setError(null);
945
+ attemptRef.current = 0;
946
+ setReconnecting(false);
947
+ setIsReady(true);
948
+ }
949
+ },
950
+ session
951
+ });
952
+ transportRef.current = connection;
953
+ return () => {
954
+ disposed = true;
955
+ if (timer !== void 0) clearTimeout(timer);
956
+ window.removeEventListener("online", retryNow);
957
+ connection.close();
958
+ if (transportRef.current === connection) transportRef.current = null;
959
+ };
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 }) {
743
964
  const [isOpen, setIsOpen] = useState(initiallyOpen);
965
+ const panelId = useId();
744
966
  const [error, setError] = useState(null);
745
967
  const [isConnecting, setIsConnecting] = useState(false);
746
968
  const [isReady, setIsReady] = useState(false);
@@ -755,6 +977,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
755
977
  });
756
978
  const transportRef = useRef(null);
757
979
  const listRef = useRef(null);
980
+ const identityRef = useRef(null);
758
981
  useEffect(() => {
759
982
  if (createTransport) return;
760
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");
@@ -770,41 +993,30 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
770
993
  if (!listRef.current) return;
771
994
  listRef.current.scrollTop = listRef.current.scrollHeight;
772
995
  }, [messages]);
773
- useEffect(() => {
774
- transportRef.current?.close();
775
- transportRef.current = null;
776
- setIsReady(false);
777
- if (!(session && createTransport)) return;
778
- const connection = createTransport({
779
- handlers: {
780
- onClose: () => {
781
- setIsReady(false);
782
- setAssistantThinking(false);
783
- },
784
- onError: (transportError) => {
785
- setError(transportError.message);
786
- },
787
- onEvent: (event) => {
788
- applyServerEvent(event, {
789
- setAssistantThinking,
790
- setError,
791
- setIsReady,
792
- setMessages,
793
- setSupportState
794
- });
795
- },
796
- onOpen: () => {
797
- setIsReady(true);
798
- }
799
- },
800
- session
801
- });
802
- transportRef.current = connection;
803
- return () => {
804
- connection.close();
805
- if (transportRef.current === connection) transportRef.current = null;
806
- };
807
- }, [createTransport, session]);
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
+ });
808
1020
  useEffect(() => {
809
1021
  if (!(isOpen && isReady)) return;
810
1022
  const markReadIfVisible = () => {
@@ -832,6 +1044,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
832
1044
  widgetId
833
1045
  });
834
1046
  storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1047
+ identityRef.current = identity;
835
1048
  setSupportState(nextSession.support?.state ?? null);
836
1049
  setSession(nextSession);
837
1050
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
@@ -909,21 +1122,24 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
909
1122
  const topics = visibleTopics(session, supportState);
910
1123
  return /* @__PURE__ */ jsxs("div", {
911
1124
  className: `clivly-chat-widget ${className ?? ""}`.trim(),
1125
+ "data-clivly-launcher": launcher,
1126
+ "data-clivly-placement": placement,
912
1127
  "data-clivly-theme": resolvedTheme,
913
1128
  style: themeStyle,
914
1129
  children: [
915
1130
  /* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
916
- /* @__PURE__ */ jsx("button", {
917
- className: "clivly-chat-widget__launcher",
918
- onClick: () => {
1131
+ /* @__PURE__ */ jsx(Launcher, {
1132
+ label: launcherLabel,
1133
+ onToggle: () => {
919
1134
  setIsOpen((open) => !open);
920
1135
  },
921
- type: "button",
922
- children: /* @__PURE__ */ jsx("span", { children: launcherLabel })
1136
+ open: isOpen,
1137
+ panelId
923
1138
  }),
924
1139
  isOpen ? /* @__PURE__ */ jsxs("section", {
925
1140
  "aria-label": panelTitle,
926
1141
  className: "clivly-chat-widget__panel",
1142
+ id: panelId,
927
1143
  children: [
928
1144
  /* @__PURE__ */ jsxs("header", {
929
1145
  className: "clivly-chat-widget__header",
@@ -996,8 +1212,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
996
1212
  }), /* @__PURE__ */ jsxs("div", {
997
1213
  className: "clivly-chat-widget__actions",
998
1214
  children: [/* @__PURE__ */ jsx("span", {
1215
+ "aria-live": "polite",
999
1216
  className: "clivly-chat-widget__subtitle",
1000
- children: composerStatusText(Boolean(createTransport), isReady)
1217
+ children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
1001
1218
  }), /* @__PURE__ */ jsx("button", {
1002
1219
  className: "clivly-chat-widget__button",
1003
1220
  disabled: !(messageDraft.trim() && canSend),
@@ -1146,6 +1363,26 @@ function createClivlyTransport(options = {}) {
1146
1363
  });
1147
1364
  }
1148
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
1149
1386
  //#region src/session.ts
1150
1387
  function isWidgetSessionPayload(value) {
1151
1388
  return typeof value === "object" && value !== null;
@@ -1243,6 +1480,8 @@ function mountChatWidgetScript(script = document.currentScript) {
1243
1480
  const { root, setTheme } = createWidgetMount({
1244
1481
  container,
1245
1482
  defaultVisitor: readScriptVisitor(script.dataset),
1483
+ launcher: parseLauncher(script.dataset.launcher),
1484
+ placement: parsePlacement(script.dataset.placement),
1246
1485
  sessionUrl,
1247
1486
  socketUrlTemplate: script.dataset.socketUrlTemplate,
1248
1487
  theme: parseTheme(script.dataset.theme),