@clivly/chat-widget 0.16.0 → 0.17.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.
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let react_dom_client = require("react-dom/client");
3
3
  let react = require("react");
4
4
  let react_jsx_runtime = require("react/jsx-runtime");
5
- //#region src/theme.ts
5
+ // src/theme.ts
6
6
  const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
7
  /** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
8
8
  const THEME_ATTRIBUTE = "data-theme";
@@ -110,8 +110,51 @@ function parseTheme(value) {
110
110
  if (value === "auto" || value === "dark" || value === "light") return value;
111
111
  return "auto";
112
112
  }
113
- //#endregion
114
- //#region src/chat-widget.tsx
113
+ // src/visitor-token-storage.ts
114
+ /** Normalised the way the server matches contacts: trimmed, lower-case. */
115
+ function normalizeVisitorEmail(email) {
116
+ return email.trim().toLowerCase();
117
+ }
118
+ const HASH_MODULUS = 4294967291;
119
+ /**
120
+ * A polynomial string hash, reduced mod a prime below 2^32: stable,
121
+ * synchronous, and exact in doubles (every intermediate stays under 2^53).
122
+ * Not a security primitive.
123
+ */
124
+ function shortHash(value) {
125
+ let hash = 5381;
126
+ for (const char of value) hash = (hash * 33 + (char.codePointAt(0) ?? 0)) % HASH_MODULUS;
127
+ return hash.toString(16).padStart(8, "0");
128
+ }
129
+ function visitorTokenKey(widgetId, email) {
130
+ return `clivly-chat-widget:${widgetId}:visitor-token:${shortHash(normalizeVisitorEmail(email))}`;
131
+ }
132
+ /** The single per-widget key every widget before this change wrote. */
133
+ function legacyVisitorTokenKey(widgetId) {
134
+ return `clivly-chat-widget:${widgetId}:visitor-token`;
135
+ }
136
+ /**
137
+ * The token to offer for this person. Falls back to the legacy per-widget
138
+ * token once, so visitors who chatted before this change still resume — the
139
+ * server only honours it if they are the identity that thread belongs to.
140
+ */
141
+ function readVisitorToken(storage, widgetId, email) {
142
+ if (!storage) return null;
143
+ try {
144
+ return storage.getItem(visitorTokenKey(widgetId, email)) ?? storage.getItem(legacyVisitorTokenKey(widgetId));
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ /** Store this person's token and retire the legacy shared one. */
150
+ function writeVisitorToken(storage, widgetId, email, token) {
151
+ if (!storage) return;
152
+ try {
153
+ storage.setItem(visitorTokenKey(widgetId, email), token);
154
+ storage.removeItem?.(legacyVisitorTokenKey(widgetId));
155
+ } catch {}
156
+ }
157
+ // src/chat-widget.tsx
115
158
  const DELIVERY_LABELS = {
116
159
  failed: "Failed to send",
117
160
  pending: "Sending…",
@@ -533,9 +576,6 @@ function createLocalMessage(message, delivery = "sent") {
533
576
  delivery
534
577
  };
535
578
  }
536
- function createStorageKey(widgetId) {
537
- return `clivly-chat-widget:${widgetId}:visitor-token`;
538
- }
539
579
  function formatTimestamp(value) {
540
580
  const date = new Date(value);
541
581
  if (Number.isNaN(date.getTime())) return "";
@@ -961,6 +1001,33 @@ function useLiveConnection({ actions, createTransport, refreshSession, session,
961
1001
  }, [createTransport, session]);
962
1002
  return reconnecting;
963
1003
  }
1004
+ /** Who the host says is signed in: their normalised email, or null. */
1005
+ function hostIdentityOf(defaultVisitor) {
1006
+ return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
1007
+ }
1008
+ /**
1009
+ * The host's signed-in visitor changed under an open session — a sign-out, or
1010
+ * a different account signing in without a page reload. The session on
1011
+ * screen is the previous person's: drop it, its transcript and its socket, so
1012
+ * the next open starts as whoever is signed in now (chat widget session
1013
+ * review 2026-10-28, S1). A session the visitor started from the pre-chat
1014
+ * form is theirs and is left alone.
1015
+ */
1016
+ function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
1017
+ const resetRef = (0, react.useRef)(reset);
1018
+ (0, react.useEffect)(() => {
1019
+ resetRef.current = reset;
1020
+ });
1021
+ (0, react.useEffect)(() => {
1022
+ const current = identityRef.current;
1023
+ if (!(current && sessionSourceRef.current === "host")) return;
1024
+ if (hostIdentity === normalizeVisitorEmail(current.email)) return;
1025
+ generationRef.current += 1;
1026
+ identityRef.current = null;
1027
+ sessionSourceRef.current = null;
1028
+ resetRef.current();
1029
+ }, [hostIdentity]);
1030
+ }
964
1031
  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
1032
  const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
966
1033
  const panelId = (0, react.useId)();
@@ -979,6 +1046,11 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
979
1046
  const transportRef = (0, react.useRef)(null);
980
1047
  const listRef = (0, react.useRef)(null);
981
1048
  const identityRef = (0, react.useRef)(null);
1049
+ const sessionSourceRef = (0, react.useRef)(null);
1050
+ const generationRef = (0, react.useRef)(0);
1051
+ const hostIdentity = hostIdentityOf(defaultVisitor);
1052
+ const latestHostIdentityRef = (0, react.useRef)(hostIdentity);
1053
+ latestHostIdentityRef.current = hostIdentity;
982
1054
  (0, react.useEffect)(() => {
983
1055
  if (createTransport) return;
984
1056
  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");
@@ -1005,12 +1077,14 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1005
1077
  refreshSession: async (current) => {
1006
1078
  const identity = identityRef.current;
1007
1079
  if (!identity) throw new Error("No visitor to reconnect as.");
1080
+ const generation = generationRef.current;
1008
1081
  const next = await getSession({
1009
1082
  visitor: identity,
1010
1083
  visitorToken: current.visitorToken,
1011
1084
  widgetId
1012
1085
  });
1013
- storage?.setItem(createStorageKey(widgetId), next.visitorToken);
1086
+ if (generation !== generationRef.current) return;
1087
+ writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
1014
1088
  setSupportState(next.support?.state ?? null);
1015
1089
  setSession(next);
1016
1090
  },
@@ -1035,31 +1109,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1035
1109
  isReady,
1036
1110
  messages
1037
1111
  ]);
1038
- const startSession = (0, react.useCallback)(async (identity) => {
1112
+ const startSession = (0, react.useCallback)(async (identity, source = "form") => {
1039
1113
  setError(null);
1040
1114
  setIsConnecting(true);
1115
+ const generation = generationRef.current;
1116
+ const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
1041
1117
  try {
1042
1118
  const nextSession = await getSession({
1043
1119
  visitor: identity,
1044
- visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
1120
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
1045
1121
  widgetId
1046
1122
  });
1047
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1123
+ if (isStale()) return;
1124
+ writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
1048
1125
  identityRef.current = identity;
1126
+ sessionSourceRef.current = source;
1049
1127
  setSupportState(nextSession.support?.state ?? null);
1050
1128
  setSession(nextSession);
1051
1129
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
1052
1130
  } catch (sessionError) {
1131
+ if (isStale()) return;
1053
1132
  const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
1054
1133
  setError(resolved.message);
1055
1134
  } finally {
1056
- setIsConnecting(false);
1135
+ if (generation === generationRef.current) setIsConnecting(false);
1057
1136
  }
1058
1137
  }, [
1059
1138
  getSession,
1060
1139
  storage,
1061
1140
  widgetId
1062
1141
  ]);
1142
+ useResetOnHostIdentityChange({
1143
+ generationRef,
1144
+ hostIdentity,
1145
+ identityRef,
1146
+ reset: () => {
1147
+ setSession(null);
1148
+ setMessages([]);
1149
+ setSupportState(null);
1150
+ setError(null);
1151
+ setIsConnecting(false);
1152
+ setVisitor({
1153
+ email: defaultVisitor?.email ?? "",
1154
+ name: defaultVisitor?.name ?? ""
1155
+ });
1156
+ },
1157
+ sessionSourceRef
1158
+ });
1063
1159
  const submitVisitor = async (event) => {
1064
1160
  event.preventDefault();
1065
1161
  await startSession(visitor);
@@ -1070,7 +1166,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1070
1166
  startSession({
1071
1167
  email: defaultVisitor.email.trim(),
1072
1168
  name: defaultVisitor.name.trim()
1073
- }).catch(() => {});
1169
+ }, "host").catch(() => {});
1074
1170
  }, [
1075
1171
  defaultVisitor,
1076
1172
  error,
@@ -1299,8 +1395,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1299
1395
  ]
1300
1396
  });
1301
1397
  }
1302
- //#endregion
1303
- //#region src/transport.ts
1398
+ // src/transport.ts
1304
1399
  function isServerEvent(value) {
1305
1400
  if (typeof value !== "object" || value === null) return false;
1306
1401
  return typeof value.type === "string";
@@ -1345,8 +1440,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
1345
1440
  };
1346
1441
  };
1347
1442
  }
1348
- //#endregion
1349
- //#region src/clivly-transport.ts
1443
+ // src/clivly-transport.ts
1350
1444
  /**
1351
1445
  * The transport a host app should use against a real Clivly backend.
1352
1446
  *
@@ -1363,8 +1457,7 @@ function createClivlyTransport(options = {}) {
1363
1457
  }
1364
1458
  });
1365
1459
  }
1366
- //#endregion
1367
- //#region src/layout.ts
1460
+ // src/layout.ts
1368
1461
  const PLACEMENTS = [
1369
1462
  "bottom-left",
1370
1463
  "bottom-right",
@@ -1383,8 +1476,7 @@ function parsePlacement(value) {
1383
1476
  function parseLauncher(value) {
1384
1477
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1385
1478
  }
1386
- //#endregion
1387
- //#region src/session.ts
1479
+ // src/session.ts
1388
1480
  function isWidgetSessionPayload(value) {
1389
1481
  return typeof value === "object" && value !== null;
1390
1482
  }
@@ -1406,8 +1498,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1406
1498
  return data;
1407
1499
  };
1408
1500
  }
1409
- //#endregion
1410
- //#region src/browser-mount.tsx
1501
+ // src/browser-mount.tsx
1411
1502
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1412
1503
  /**
1413
1504
  * The identity a script-tag host already knows, read off the tag.
@@ -1499,7 +1590,6 @@ function autoMountChatWidgetScript(script = document.currentScript) {
1499
1590
  if (!script || scriptAlreadyMounted(script)) return null;
1500
1591
  return mountChatWidgetScript(script);
1501
1592
  }
1502
- //#endregion
1503
1593
  exports.autoMountChatWidgetScript = autoMountChatWidgetScript;
1504
1594
  exports.mountChatWidget = mountChatWidget;
1505
1595
  exports.mountChatWidgetScript = mountChatWidgetScript;
@@ -1,6 +1,5 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } from "react";
3
- //#region src/layout.d.ts
4
3
  /**
5
4
  * Where the widget sits on the host page, and what its launcher looks like.
6
5
  *
@@ -20,8 +19,6 @@ type ChatWidgetPlacement = "bottom-left" | "bottom-right" | "inline";
20
19
  * The label stays the button's accessible name in every variant.
21
20
  */
22
21
  type ChatWidgetLauncher = "auto" | "icon" | "pill";
23
- //#endregion
24
- //#region src/theme.d.ts
25
22
  /**
26
23
  * Theme resolution for the widget.
27
24
  *
@@ -32,8 +29,6 @@ type ChatWidgetLauncher = "auto" | "icon" | "pill";
32
29
  * does, and why it is a plain DOM module rather than a stylesheet.
33
30
  */
34
31
  type ChatWidgetTheme = "auto" | "dark" | "light";
35
- //#endregion
36
- //#region src/types.d.ts
37
32
  type WidgetSenderType = "visitor" | "operator" | "system";
38
33
  interface WidgetConfig {
39
34
  brandColor?: string | null;
@@ -145,8 +140,6 @@ interface WidgetErrorEvent {
145
140
  type: "error";
146
141
  }
147
142
  type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
148
- //#endregion
149
- //#region src/transport.d.ts
150
143
  interface ChatTransportHandlers {
151
144
  onClose: () => void;
152
145
  onError: (error: Error) => void;
@@ -173,8 +166,21 @@ interface CreateChatTransportOptions {
173
166
  session: WidgetSessionPayload;
174
167
  }
175
168
  type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTransportConnection;
176
- //#endregion
177
- //#region src/chat-widget.d.ts
169
+ /**
170
+ * Where the widget keeps the visitor token that resumes a conversation.
171
+ *
172
+ * Keyed by widget AND by who the token was issued to. One key per widget used
173
+ * to hand the token to whoever opened the widget next on that browser — after
174
+ * a sign-out, or between two people who don't sign in at all — and the server
175
+ * resumed the previous person's thread for them (chat widget session review
176
+ * 2026-10-28, S1). The server now refuses that resume on its own; this keeps
177
+ * each person's token to themselves so a returning visitor still resumes.
178
+ *
179
+ * The identity part is a short hash of the normalised email, so the address
180
+ * itself isn't written into storage keys. It only partitions storage: the
181
+ * server's identity check is the security boundary, not this.
182
+ */
183
+ type VisitorTokenStorage = Pick<Storage, "getItem" | "setItem"> & Partial<Pick<Storage, "removeItem">>;
178
184
  interface ChatWidgetProps {
179
185
  className?: string;
180
186
  createTransport?: CreateChatTransport;
@@ -209,7 +215,12 @@ interface ChatWidgetProps {
209
215
  * `style` to sit beneath the host's own dialogs.
210
216
  */
211
217
  placement?: ChatWidgetPlacement;
212
- storage?: Pick<Storage, "getItem" | "setItem">;
218
+ /**
219
+ * Where the visitor token is kept, per widget and per visitor (see
220
+ * visitor-token-storage.ts). `removeItem` is optional; with it, the
221
+ * pre-0.16 shared token is retired after its first use.
222
+ */
223
+ storage?: VisitorTokenStorage;
213
224
  /**
214
225
  * Inline styles on the widget root. The intended use is overriding palette
215
226
  * tokens — `{ "--clivly-chat-panel": "#0b0f19" }` — which works because
@@ -224,8 +235,6 @@ interface ChatWidgetProps {
224
235
  theme?: ChatWidgetTheme;
225
236
  widgetId: string;
226
237
  }
227
- //#endregion
228
- //#region src/browser-mount.d.ts
229
238
  export interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
230
239
  container: Element | ShadowRoot;
231
240
  sessionUrl: string;
@@ -250,4 +259,4 @@ export interface ScriptMountResult extends ChatWidgetMount {
250
259
  }
251
260
  export declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
252
261
  export declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
253
- //#endregion
262
+ export {};
@@ -1,6 +1,5 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } from "react";
3
- //#region src/layout.d.ts
4
3
  /**
5
4
  * Where the widget sits on the host page, and what its launcher looks like.
6
5
  *
@@ -20,8 +19,6 @@ type ChatWidgetPlacement = "bottom-left" | "bottom-right" | "inline";
20
19
  * The label stays the button's accessible name in every variant.
21
20
  */
22
21
  type ChatWidgetLauncher = "auto" | "icon" | "pill";
23
- //#endregion
24
- //#region src/theme.d.ts
25
22
  /**
26
23
  * Theme resolution for the widget.
27
24
  *
@@ -32,8 +29,6 @@ type ChatWidgetLauncher = "auto" | "icon" | "pill";
32
29
  * does, and why it is a plain DOM module rather than a stylesheet.
33
30
  */
34
31
  type ChatWidgetTheme = "auto" | "dark" | "light";
35
- //#endregion
36
- //#region src/types.d.ts
37
32
  type WidgetSenderType = "visitor" | "operator" | "system";
38
33
  interface WidgetConfig {
39
34
  brandColor?: string | null;
@@ -145,8 +140,6 @@ interface WidgetErrorEvent {
145
140
  type: "error";
146
141
  }
147
142
  type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
148
- //#endregion
149
- //#region src/transport.d.ts
150
143
  interface ChatTransportHandlers {
151
144
  onClose: () => void;
152
145
  onError: (error: Error) => void;
@@ -173,8 +166,21 @@ interface CreateChatTransportOptions {
173
166
  session: WidgetSessionPayload;
174
167
  }
175
168
  type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTransportConnection;
176
- //#endregion
177
- //#region src/chat-widget.d.ts
169
+ /**
170
+ * Where the widget keeps the visitor token that resumes a conversation.
171
+ *
172
+ * Keyed by widget AND by who the token was issued to. One key per widget used
173
+ * to hand the token to whoever opened the widget next on that browser — after
174
+ * a sign-out, or between two people who don't sign in at all — and the server
175
+ * resumed the previous person's thread for them (chat widget session review
176
+ * 2026-10-28, S1). The server now refuses that resume on its own; this keeps
177
+ * each person's token to themselves so a returning visitor still resumes.
178
+ *
179
+ * The identity part is a short hash of the normalised email, so the address
180
+ * itself isn't written into storage keys. It only partitions storage: the
181
+ * server's identity check is the security boundary, not this.
182
+ */
183
+ type VisitorTokenStorage = Pick<Storage, "getItem" | "setItem"> & Partial<Pick<Storage, "removeItem">>;
178
184
  interface ChatWidgetProps {
179
185
  className?: string;
180
186
  createTransport?: CreateChatTransport;
@@ -209,7 +215,12 @@ interface ChatWidgetProps {
209
215
  * `style` to sit beneath the host's own dialogs.
210
216
  */
211
217
  placement?: ChatWidgetPlacement;
212
- storage?: Pick<Storage, "getItem" | "setItem">;
218
+ /**
219
+ * Where the visitor token is kept, per widget and per visitor (see
220
+ * visitor-token-storage.ts). `removeItem` is optional; with it, the
221
+ * pre-0.16 shared token is retired after its first use.
222
+ */
223
+ storage?: VisitorTokenStorage;
213
224
  /**
214
225
  * Inline styles on the widget root. The intended use is overriding palette
215
226
  * tokens — `{ "--clivly-chat-panel": "#0b0f19" }` — which works because
@@ -224,8 +235,6 @@ interface ChatWidgetProps {
224
235
  theme?: ChatWidgetTheme;
225
236
  widgetId: string;
226
237
  }
227
- //#endregion
228
- //#region src/browser-mount.d.ts
229
238
  export interface MountChatWidgetOptions extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
230
239
  container: Element | ShadowRoot;
231
240
  sessionUrl: string;
@@ -250,4 +259,4 @@ export interface ScriptMountResult extends ChatWidgetMount {
250
259
  }
251
260
  export declare function mountChatWidgetScript(script?: HTMLScriptElement): ScriptMountResult;
252
261
  export declare function autoMountChatWidgetScript(script?: HTMLScriptElement | null): ScriptMountResult | null;
253
- //#endregion
262
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- //#region src/theme.ts
4
+ // src/theme.ts
5
5
  const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
6
6
  /** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
7
7
  const THEME_ATTRIBUTE = "data-theme";
@@ -109,8 +109,51 @@ function parseTheme(value) {
109
109
  if (value === "auto" || value === "dark" || value === "light") return value;
110
110
  return "auto";
111
111
  }
112
- //#endregion
113
- //#region src/chat-widget.tsx
112
+ // src/visitor-token-storage.ts
113
+ /** Normalised the way the server matches contacts: trimmed, lower-case. */
114
+ function normalizeVisitorEmail(email) {
115
+ return email.trim().toLowerCase();
116
+ }
117
+ const HASH_MODULUS = 4294967291;
118
+ /**
119
+ * A polynomial string hash, reduced mod a prime below 2^32: stable,
120
+ * synchronous, and exact in doubles (every intermediate stays under 2^53).
121
+ * Not a security primitive.
122
+ */
123
+ function shortHash(value) {
124
+ let hash = 5381;
125
+ for (const char of value) hash = (hash * 33 + (char.codePointAt(0) ?? 0)) % HASH_MODULUS;
126
+ return hash.toString(16).padStart(8, "0");
127
+ }
128
+ function visitorTokenKey(widgetId, email) {
129
+ return `clivly-chat-widget:${widgetId}:visitor-token:${shortHash(normalizeVisitorEmail(email))}`;
130
+ }
131
+ /** The single per-widget key every widget before this change wrote. */
132
+ function legacyVisitorTokenKey(widgetId) {
133
+ return `clivly-chat-widget:${widgetId}:visitor-token`;
134
+ }
135
+ /**
136
+ * The token to offer for this person. Falls back to the legacy per-widget
137
+ * token once, so visitors who chatted before this change still resume — the
138
+ * server only honours it if they are the identity that thread belongs to.
139
+ */
140
+ function readVisitorToken(storage, widgetId, email) {
141
+ if (!storage) return null;
142
+ try {
143
+ return storage.getItem(visitorTokenKey(widgetId, email)) ?? storage.getItem(legacyVisitorTokenKey(widgetId));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** Store this person's token and retire the legacy shared one. */
149
+ function writeVisitorToken(storage, widgetId, email, token) {
150
+ if (!storage) return;
151
+ try {
152
+ storage.setItem(visitorTokenKey(widgetId, email), token);
153
+ storage.removeItem?.(legacyVisitorTokenKey(widgetId));
154
+ } catch {}
155
+ }
156
+ // src/chat-widget.tsx
114
157
  const DELIVERY_LABELS = {
115
158
  failed: "Failed to send",
116
159
  pending: "Sending…",
@@ -532,9 +575,6 @@ function createLocalMessage(message, delivery = "sent") {
532
575
  delivery
533
576
  };
534
577
  }
535
- function createStorageKey(widgetId) {
536
- return `clivly-chat-widget:${widgetId}:visitor-token`;
537
- }
538
578
  function formatTimestamp(value) {
539
579
  const date = new Date(value);
540
580
  if (Number.isNaN(date.getTime())) return "";
@@ -960,6 +1000,33 @@ function useLiveConnection({ actions, createTransport, refreshSession, session,
960
1000
  }, [createTransport, session]);
961
1001
  return reconnecting;
962
1002
  }
1003
+ /** Who the host says is signed in: their normalised email, or null. */
1004
+ function hostIdentityOf(defaultVisitor) {
1005
+ return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
1006
+ }
1007
+ /**
1008
+ * The host's signed-in visitor changed under an open session — a sign-out, or
1009
+ * a different account signing in without a page reload. The session on
1010
+ * screen is the previous person's: drop it, its transcript and its socket, so
1011
+ * the next open starts as whoever is signed in now (chat widget session
1012
+ * review 2026-10-28, S1). A session the visitor started from the pre-chat
1013
+ * form is theirs and is left alone.
1014
+ */
1015
+ function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
1016
+ const resetRef = useRef(reset);
1017
+ useEffect(() => {
1018
+ resetRef.current = reset;
1019
+ });
1020
+ useEffect(() => {
1021
+ const current = identityRef.current;
1022
+ if (!(current && sessionSourceRef.current === "host")) return;
1023
+ if (hostIdentity === normalizeVisitorEmail(current.email)) return;
1024
+ generationRef.current += 1;
1025
+ identityRef.current = null;
1026
+ sessionSourceRef.current = null;
1027
+ resetRef.current();
1028
+ }, [hostIdentity]);
1029
+ }
963
1030
  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
1031
  const [isOpen, setIsOpen] = useState(initiallyOpen);
965
1032
  const panelId = useId();
@@ -978,6 +1045,11 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
978
1045
  const transportRef = useRef(null);
979
1046
  const listRef = useRef(null);
980
1047
  const identityRef = useRef(null);
1048
+ const sessionSourceRef = useRef(null);
1049
+ const generationRef = useRef(0);
1050
+ const hostIdentity = hostIdentityOf(defaultVisitor);
1051
+ const latestHostIdentityRef = useRef(hostIdentity);
1052
+ latestHostIdentityRef.current = hostIdentity;
981
1053
  useEffect(() => {
982
1054
  if (createTransport) return;
983
1055
  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");
@@ -1004,12 +1076,14 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1004
1076
  refreshSession: async (current) => {
1005
1077
  const identity = identityRef.current;
1006
1078
  if (!identity) throw new Error("No visitor to reconnect as.");
1079
+ const generation = generationRef.current;
1007
1080
  const next = await getSession({
1008
1081
  visitor: identity,
1009
1082
  visitorToken: current.visitorToken,
1010
1083
  widgetId
1011
1084
  });
1012
- storage?.setItem(createStorageKey(widgetId), next.visitorToken);
1085
+ if (generation !== generationRef.current) return;
1086
+ writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
1013
1087
  setSupportState(next.support?.state ?? null);
1014
1088
  setSession(next);
1015
1089
  },
@@ -1034,31 +1108,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1034
1108
  isReady,
1035
1109
  messages
1036
1110
  ]);
1037
- const startSession = useCallback(async (identity) => {
1111
+ const startSession = useCallback(async (identity, source = "form") => {
1038
1112
  setError(null);
1039
1113
  setIsConnecting(true);
1114
+ const generation = generationRef.current;
1115
+ const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
1040
1116
  try {
1041
1117
  const nextSession = await getSession({
1042
1118
  visitor: identity,
1043
- visitorToken: storage?.getItem(createStorageKey(widgetId)) ?? null,
1119
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
1044
1120
  widgetId
1045
1121
  });
1046
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
1122
+ if (isStale()) return;
1123
+ writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
1047
1124
  identityRef.current = identity;
1125
+ sessionSourceRef.current = source;
1048
1126
  setSupportState(nextSession.support?.state ?? null);
1049
1127
  setSession(nextSession);
1050
1128
  setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
1051
1129
  } catch (sessionError) {
1130
+ if (isStale()) return;
1052
1131
  const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
1053
1132
  setError(resolved.message);
1054
1133
  } finally {
1055
- setIsConnecting(false);
1134
+ if (generation === generationRef.current) setIsConnecting(false);
1056
1135
  }
1057
1136
  }, [
1058
1137
  getSession,
1059
1138
  storage,
1060
1139
  widgetId
1061
1140
  ]);
1141
+ useResetOnHostIdentityChange({
1142
+ generationRef,
1143
+ hostIdentity,
1144
+ identityRef,
1145
+ reset: () => {
1146
+ setSession(null);
1147
+ setMessages([]);
1148
+ setSupportState(null);
1149
+ setError(null);
1150
+ setIsConnecting(false);
1151
+ setVisitor({
1152
+ email: defaultVisitor?.email ?? "",
1153
+ name: defaultVisitor?.name ?? ""
1154
+ });
1155
+ },
1156
+ sessionSourceRef
1157
+ });
1062
1158
  const submitVisitor = async (event) => {
1063
1159
  event.preventDefault();
1064
1160
  await startSession(visitor);
@@ -1069,7 +1165,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1069
1165
  startSession({
1070
1166
  email: defaultVisitor.email.trim(),
1071
1167
  name: defaultVisitor.name.trim()
1072
- }).catch(() => {});
1168
+ }, "host").catch(() => {});
1073
1169
  }, [
1074
1170
  defaultVisitor,
1075
1171
  error,
@@ -1298,8 +1394,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1298
1394
  ]
1299
1395
  });
1300
1396
  }
1301
- //#endregion
1302
- //#region src/transport.ts
1397
+ // src/transport.ts
1303
1398
  function isServerEvent(value) {
1304
1399
  if (typeof value !== "object" || value === null) return false;
1305
1400
  return typeof value.type === "string";
@@ -1344,8 +1439,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
1344
1439
  };
1345
1440
  };
1346
1441
  }
1347
- //#endregion
1348
- //#region src/clivly-transport.ts
1442
+ // src/clivly-transport.ts
1349
1443
  /**
1350
1444
  * The transport a host app should use against a real Clivly backend.
1351
1445
  *
@@ -1362,8 +1456,7 @@ function createClivlyTransport(options = {}) {
1362
1456
  }
1363
1457
  });
1364
1458
  }
1365
- //#endregion
1366
- //#region src/layout.ts
1459
+ // src/layout.ts
1367
1460
  const PLACEMENTS = [
1368
1461
  "bottom-left",
1369
1462
  "bottom-right",
@@ -1382,8 +1475,7 @@ function parsePlacement(value) {
1382
1475
  function parseLauncher(value) {
1383
1476
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1384
1477
  }
1385
- //#endregion
1386
- //#region src/session.ts
1478
+ // src/session.ts
1387
1479
  function isWidgetSessionPayload(value) {
1388
1480
  return typeof value === "object" && value !== null;
1389
1481
  }
@@ -1405,8 +1497,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1405
1497
  return data;
1406
1498
  };
1407
1499
  }
1408
- //#endregion
1409
- //#region src/browser-mount.tsx
1500
+ // src/browser-mount.tsx
1410
1501
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1411
1502
  /**
1412
1503
  * The identity a script-tag host already knows, read off the tag.
@@ -1498,5 +1589,4 @@ function autoMountChatWidgetScript(script = document.currentScript) {
1498
1589
  if (!script || scriptAlreadyMounted(script)) return null;
1499
1590
  return mountChatWidgetScript(script);
1500
1591
  }
1501
- //#endregion
1502
1592
  export { autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };