@oyehello/chat 0.3.0 → 0.5.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/README.md CHANGED
@@ -6,6 +6,8 @@ The widget connects lazily (a session opens the first time the visitor opens the
6
6
 
7
7
  ### Attributes
8
8
 
9
+ The **accent/logo/name** (workspace brand), the **teaser** (a chat-agent setting), and the **quick-reply chips** (generated from the agent's flow after each reply) are served automatically by the API — the attributes below just override them.
10
+
9
11
  | Attribute | Required | What it is |
10
12
  | --- | --- | --- |
11
13
  | `api-key` | yes | Publishable key (`ohk_pub_…`) |
@@ -34,9 +36,10 @@ npm install @oyehello/chat
34
36
  ```
35
37
  Or via a plain script tag (no bundler):
36
38
  ```html
37
- <script src="https://unpkg.com/@oyehello/chat"></script>
39
+ <script src="https://unpkg.com/@oyehello/chat@0.5.0"></script>
38
40
  <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
39
41
  ```
42
+ Pin a specific version (`@0.5.0` above) rather than the bare package name, so a customer's site loads the exact widget it was tested against instead of silently auto-upgrading on every publish.
40
43
  Theme the accent with the `--oc-accent` CSS variable.
41
44
 
42
45
  ### React
package/dist/index.cjs CHANGED
@@ -21,6 +21,9 @@ var ChatApiError = class extends Error {
21
21
  meta;
22
22
  };
23
23
 
24
+ // src/protocol.ts
25
+ var CHAT_PROTOCOL_VERSION = 1;
26
+
24
27
  // src/transport.ts
25
28
  async function asError(res, fallbackCode) {
26
29
  let body = {};
@@ -31,9 +34,10 @@ async function asError(res, fallbackCode) {
31
34
  return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
32
35
  }
33
36
  async function openSession(opts) {
34
- const body = { agentId: opts.agentId };
37
+ const body = { agentId: opts.agentId, protocol: CHAT_PROTOCOL_VERSION };
35
38
  if (opts.user) body.user = opts.user;
36
39
  if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
40
+ if (opts.fingerprint) body.fingerprint = opts.fingerprint;
37
41
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
38
42
  method: "POST",
39
43
  credentials: "omit",
@@ -43,6 +47,15 @@ async function openSession(opts) {
43
47
  if (!res.ok) throw await asError(res);
44
48
  return await res.json();
45
49
  }
50
+ async function getWidgetConfig(opts) {
51
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/widget-config?agentId=${encodeURIComponent(opts.agentId)}`, {
52
+ method: "GET",
53
+ credentials: "omit",
54
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
55
+ });
56
+ if (!res.ok) throw await asError(res);
57
+ return await res.json();
58
+ }
46
59
  async function postMessage(opts) {
47
60
  const body = { text: opts.text };
48
61
  if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
@@ -108,6 +121,50 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
108
121
  if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
109
122
  }
110
123
 
124
+ // src/fingerprint.ts
125
+ async function sha256Hex(input) {
126
+ const g = globalThis;
127
+ try {
128
+ if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
129
+ const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
130
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
131
+ }
132
+ } catch {
133
+ }
134
+ let h = 5381;
135
+ for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
136
+ return h.toString(16).padStart(8, "0");
137
+ }
138
+ async function computeDeviceFingerprint() {
139
+ try {
140
+ const nav = typeof navigator !== "undefined" ? navigator : null;
141
+ if (!nav) return null;
142
+ const scr = typeof screen !== "undefined" ? screen : null;
143
+ const tz = (() => {
144
+ try {
145
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
146
+ } catch {
147
+ return "";
148
+ }
149
+ })();
150
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
151
+ const signals = [
152
+ nav.userAgent,
153
+ Array.isArray(nav.languages) ? nav.languages.join(",") : "",
154
+ nav.language,
155
+ nav.platform,
156
+ nav.hardwareConcurrency,
157
+ nav.deviceMemory,
158
+ scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
159
+ dpr,
160
+ tz
161
+ ].map((v) => String(v ?? "")).join("|");
162
+ return await sha256Hex(signals);
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
111
168
  // src/client.ts
112
169
  var seq = 0;
113
170
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -128,6 +185,8 @@ function createChatClient(options) {
128
185
  let reconnectTimer = null;
129
186
  let reconnectAttempts = 0;
130
187
  let closed = false;
188
+ let warnedProtocol = false;
189
+ let deviceFingerprint;
131
190
  const toolHandlers = /* @__PURE__ */ new Map();
132
191
  const clearReconnect = () => {
133
192
  if (reconnectTimer) {
@@ -220,6 +279,14 @@ function createChatClient(options) {
220
279
  }
221
280
  } else if (ev.type === "agent_typing") {
222
281
  emit({ type: "agentTyping", active: !!ev.active });
282
+ } else if (ev.type === "suggestions") {
283
+ emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
284
+ } else if (ev.type === "done") {
285
+ closed = true;
286
+ clearReconnect();
287
+ es?.close();
288
+ es = null;
289
+ setState("ended");
223
290
  }
224
291
  };
225
292
  const openStream = async () => {
@@ -262,7 +329,15 @@ function createChatClient(options) {
262
329
  }, delay);
263
330
  };
264
331
  const open = async () => {
265
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
332
+ if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
333
+ session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
334
+ if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
335
+ warnedProtocol = true;
336
+ try {
337
+ console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
338
+ } catch {
339
+ }
340
+ }
266
341
  emit({ type: "agent", agent: session.agent });
267
342
  await openStream();
268
343
  };
@@ -287,6 +362,9 @@ function createChatClient(options) {
287
362
  if (!session) throw new Error("call start() before getAttachmentUrl");
288
363
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
289
364
  },
365
+ getWidgetConfig() {
366
+ return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
367
+ },
290
368
  async start() {
291
369
  if (state === "opening" || state === "ready") return;
292
370
  closed = false;
@@ -350,6 +428,20 @@ function createChatClient(options) {
350
428
  es?.close();
351
429
  es = null;
352
430
  setState("ended");
431
+ },
432
+ reset() {
433
+ es?.close();
434
+ es = null;
435
+ clearReconnect();
436
+ session = null;
437
+ streamingId = null;
438
+ handoffState = "ai";
439
+ messages.length = 0;
440
+ seenTurnIds.clear();
441
+ seenCallIds.clear();
442
+ emit({ type: "messages", messages: [] });
443
+ emit({ type: "handoff", state: "ai" });
444
+ setState("idle");
353
445
  }
354
446
  };
355
447
  }
@@ -533,8 +625,17 @@ var STYLES = `
533
625
  .i-expand { opacity: 1; } .i-shrink { opacity: 0; }
534
626
  .oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
535
627
 
536
- .err { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: #fff; background: var(--oc-danger); }
537
- .err .retry { padding: 3px 10px; font-size: 11px; background: rgba(255,255,255,0.2); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
628
+ /* Inline connection error, shown inside the chat window (no banner) */
629
+ .oc-err { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px; text-align: center; }
630
+ .oc-err-t { font-size: 13px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
631
+ .oc-err-retry { padding: 7px 15px; font-size: 12.5px; font-weight: 500; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border); background: var(--oc-tint-2); color: var(--oc-fg); cursor: pointer; transition: background .18s var(--oc-ease); }
632
+ .oc-err-retry:hover { background: var(--oc-tint-3); }
633
+
634
+ /* Session-ended state, inside the chat window */
635
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
636
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
637
+ .oc-newmsg { padding: 9px 18px; font-size: 13px; font-weight: 600; border-radius: var(--oc-r-full); border: 1px solid var(--oc-btn-rim); background: var(--oc-ac); color: #fff; box-shadow: var(--oc-btn-edge); cursor: pointer; transition: filter .18s var(--oc-ease); }
638
+ .oc-newmsg:hover { filter: brightness(1.08); }
538
639
 
539
640
  /* \u2500\u2500 Message list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
540
641
  .body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 16px 16px 26px; background: var(--oc-bg); scroll-behavior: smooth; }
@@ -802,6 +903,24 @@ var OyehelloChatElement = class extends HTMLElement {
802
903
  renderedCount = 0;
803
904
  renderedBotFinal = 0;
804
905
  timers = [];
906
+ // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
907
+ // widget prefers these over the static `suggestions` attribute.
908
+ serverSuggestions = null;
909
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
910
+ // that element in place instead of rebuilding the whole message list on every token.
911
+ streamId = null;
912
+ streamEl = null;
913
+ audioCtx = null;
914
+ teaserText = "Need a hand? Ask me anything.";
915
+ teaserShownOnce = false;
916
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
917
+ // composer is disabled (opening a session is refused server-side anyway).
918
+ offline = false;
919
+ offlineMessage = "We're offline right now. Please check back during business hours.";
920
+ configLoaded = false;
921
+ // widget-config resolved (so we know online/offline before starting)
922
+ idleTimer = null;
923
+ idleReset = null;
805
924
  // ── shadow refs ──
806
925
  oc;
807
926
  bodyEl;
@@ -809,7 +928,6 @@ var OyehelloChatElement = class extends HTMLElement {
809
928
  fileEl;
810
929
  titleEl;
811
930
  statusEl;
812
- errEl;
813
931
  emojiListEl;
814
932
  emojiSearchEl;
815
933
  connectedCallback() {
@@ -821,6 +939,7 @@ var OyehelloChatElement = class extends HTMLElement {
821
939
  this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
822
940
  return;
823
941
  }
942
+ this.teaserText = this.getAttribute("teaser") || this.teaserText;
824
943
  this.client = createChatClient({ apiKey, agentId, baseUrl });
825
944
  if (Object.keys(this.pendingTools).length > 0) {
826
945
  this.client.registerTools(this.pendingTools);
@@ -832,6 +951,7 @@ var OyehelloChatElement = class extends HTMLElement {
832
951
  this.updateHeader();
833
952
  this.updateStatus();
834
953
  this.updateClasses();
954
+ void this.loadWidgetConfig();
835
955
  let wasOpen = false;
836
956
  try {
837
957
  wasOpen = sessionStorage.getItem(this.key("open")) === "1";
@@ -845,24 +965,93 @@ var OyehelloChatElement = class extends HTMLElement {
845
965
  this.timers.push(setTimeout(() => {
846
966
  this.oc?.classList.remove("ring");
847
967
  }, 6e3));
848
- const teaserText = this.getAttribute("teaser");
968
+ this.armIdleTeaser();
969
+ }
970
+ // Show the teaser once the visitor has been idle (no page interaction) for 10s — unless the panel
971
+ // is already open or the teaser was dismissed this session. Fires at most once.
972
+ armIdleTeaser() {
849
973
  let dismissed = false;
850
974
  try {
851
975
  dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
852
976
  } catch {
853
977
  }
854
- if (teaserText && !dismissed) {
855
- this.timers.push(setTimeout(() => {
856
- if (!this.uiOpen) {
978
+ if (dismissed) return;
979
+ const reset = () => {
980
+ if (this.idleTimer) clearTimeout(this.idleTimer);
981
+ this.idleTimer = setTimeout(() => {
982
+ if (!this.uiOpen && !this.teaserShownOnce) {
857
983
  this.teaserActive = true;
984
+ this.teaserShownOnce = true;
858
985
  this.updateClasses();
986
+ this.teardownIdle();
987
+ }
988
+ }, 1e4);
989
+ };
990
+ this.idleReset = reset;
991
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.addEventListener(ev, reset, { passive: true });
992
+ reset();
993
+ }
994
+ teardownIdle() {
995
+ if (this.idleTimer) {
996
+ clearTimeout(this.idleTimer);
997
+ this.idleTimer = null;
998
+ }
999
+ if (this.idleReset) {
1000
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.removeEventListener(ev, this.idleReset);
1001
+ this.idleReset = null;
1002
+ }
1003
+ }
1004
+ async loadWidgetConfig() {
1005
+ try {
1006
+ const cfg = await this.client.getWidgetConfig();
1007
+ if (!this.getAttribute("teaser") && cfg.teaser) {
1008
+ this.teaserText = cfg.teaser;
1009
+ this.applyTeaserText();
1010
+ }
1011
+ if (cfg.brand) {
1012
+ if (cfg.brand.accentColor) this.style.setProperty("--oc-accent", cfg.brand.accentColor);
1013
+ if (cfg.brand.name && !this.getAttribute("title")) {
1014
+ this.agentName = cfg.brand.name;
1015
+ this.updateHeader();
859
1016
  }
860
- }, 1400));
1017
+ if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
1018
+ }
1019
+ if (cfg.offline) {
1020
+ this.offline = true;
1021
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
1022
+ this.renderMessages();
1023
+ this.updateStatus();
1024
+ }
1025
+ } catch {
1026
+ } finally {
1027
+ this.configLoaded = true;
1028
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
1029
+ this.started = true;
1030
+ void this.client.start();
1031
+ }
861
1032
  }
862
1033
  }
1034
+ applyTeaserText() {
1035
+ const el = this.shadowRoot?.querySelector(".teaser-btn span:last-child");
1036
+ if (el) el.textContent = this.teaserText;
1037
+ }
1038
+ applyBrandLogo(url) {
1039
+ const set = (host) => {
1040
+ if (!host) return;
1041
+ host.textContent = "";
1042
+ const img = document.createElement("img");
1043
+ img.src = url;
1044
+ img.alt = "";
1045
+ img.style.cssText = "width:100%;height:100%;object-fit:contain";
1046
+ host.appendChild(img);
1047
+ };
1048
+ set(this.shadowRoot?.querySelector(".hd .logo"));
1049
+ set(this.shadowRoot?.querySelector(".teaser-btn .logo"));
1050
+ }
863
1051
  disconnectedCallback() {
864
1052
  this.off?.();
865
1053
  void this.client?.end();
1054
+ this.teardownIdle();
866
1055
  for (const t of this.timers) clearTimeout(t);
867
1056
  for (const url of this.imagePreviewCache.values()) {
868
1057
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -876,18 +1065,22 @@ var OyehelloChatElement = class extends HTMLElement {
876
1065
  key(k) {
877
1066
  return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
878
1067
  }
879
- get suggestions() {
1068
+ // Static fallback chips from the `suggestions` attribute (for embedders without flow-driven ones).
1069
+ get attrSuggestions() {
880
1070
  return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
881
1071
  }
1072
+ // Effective chips: server (flow) suggestions once received, else the static attribute fallback.
1073
+ get chipLabels() {
1074
+ return this.serverSuggestions ?? this.attrSuggestions;
1075
+ }
882
1076
  // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
883
1077
  renderShell() {
884
1078
  const root = this.shadowRoot;
885
- const hasTeaser = !!this.getAttribute("teaser");
886
1079
  const hasPrivacy = !!this.getAttribute("privacy-url");
887
- const teaserText = this.getAttribute("teaser") ?? "";
1080
+ const teaserText = this.teaserText;
888
1081
  root.innerHTML = `
889
1082
  <style>${STYLES}</style>
890
- <div class="oc${hasTeaser ? " has-teaser" : ""}${hasPrivacy ? " has-privacy" : ""}">
1083
+ <div class="oc has-teaser${hasPrivacy ? " has-privacy" : ""}">
891
1084
  <div class="panel" role="dialog" aria-label="Chat">
892
1085
  <div class="hd">
893
1086
  <span class="hd-grid"></span>
@@ -896,7 +1089,6 @@ var OyehelloChatElement = class extends HTMLElement {
896
1089
  <button type="button" class="hd-btn max" aria-label="Expand"><span class="i i-expand">${ICON.expand}</span><span class="i i-shrink">${ICON.shrink}</span></button>
897
1090
  <button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
898
1091
  </div>
899
- <div class="err" hidden></div>
900
1092
  <div class="body"></div>
901
1093
  <span class="emoji-scrim"></span>
902
1094
  <div class="emoji-panel">
@@ -940,7 +1132,6 @@ var OyehelloChatElement = class extends HTMLElement {
940
1132
  this.fileEl = root.querySelector('input[type="file"]');
941
1133
  this.titleEl = root.querySelector(".hd-title");
942
1134
  this.statusEl = root.querySelector(".hd-status .t");
943
- this.errEl = root.querySelector(".err");
944
1135
  this.emojiListEl = root.querySelector(".emoji-list");
945
1136
  this.emojiSearchEl = root.querySelector(".emoji-search input");
946
1137
  root.querySelector(".launcher").addEventListener("click", () => this.toggle());
@@ -1021,11 +1212,12 @@ var OyehelloChatElement = class extends HTMLElement {
1021
1212
  this.uiOpen = true;
1022
1213
  this.teaserActive = false;
1023
1214
  this.unseen = 0;
1215
+ this.teardownIdle();
1024
1216
  try {
1025
1217
  sessionStorage.setItem(this.key("open"), "1");
1026
1218
  } catch {
1027
1219
  }
1028
- if (!this.started && this.client) {
1220
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
1029
1221
  this.started = true;
1030
1222
  void this.client.start();
1031
1223
  }
@@ -1063,6 +1255,7 @@ var OyehelloChatElement = class extends HTMLElement {
1063
1255
  this.updateHeader();
1064
1256
  } else if (e.type === "handoff") this.handoffState = e.state;
1065
1257
  else if (e.type === "agentTyping") this.agentTyping = e.active;
1258
+ else if (e.type === "suggestions") this.serverSuggestions = e.items;
1066
1259
  else if (e.type === "error") this.lastError = e.error.message;
1067
1260
  this.updateStatus();
1068
1261
  this.renderMessages();
@@ -1072,26 +1265,22 @@ var OyehelloChatElement = class extends HTMLElement {
1072
1265
  }
1073
1266
  updateStatus() {
1074
1267
  if (!this.textareaEl) return;
1075
- const disabled = this.state !== "ready";
1268
+ const disabled = this.state !== "ready" || this.offline;
1076
1269
  this.textareaEl.disabled = disabled;
1077
1270
  const handoff = HANDOFF_STATUS[this.handoffState];
1078
- const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1271
+ const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1079
1272
  if (this.statusEl) this.statusEl.textContent = text;
1080
- if (this.state === "error" && this.lastError) {
1081
- this.errEl.hidden = false;
1082
- this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
1083
- this.errEl.querySelector(".retry").addEventListener("click", () => {
1084
- void this.client?.start();
1085
- });
1086
- } else {
1087
- this.errEl.hidden = true;
1088
- this.errEl.innerHTML = "";
1089
- }
1090
1273
  }
1091
1274
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1092
1275
  renderMessages() {
1093
1276
  if (!this.bodyEl) return;
1094
1277
  const body = this.bodyEl;
1278
+ const last = this.messages[this.messages.length - 1];
1279
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
1280
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
1281
+ if (!this.away) this.scrollToBottom();
1282
+ return;
1283
+ }
1095
1284
  const prevTop = body.scrollTop;
1096
1285
  const added = this.messages.length - this.renderedCount;
1097
1286
  const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
@@ -1104,17 +1293,36 @@ var OyehelloChatElement = class extends HTMLElement {
1104
1293
  })();
1105
1294
  let html = this.messages.map((m, i) => this.turnHtml(m, i === lastBot && !this.agentTyping)).join("");
1106
1295
  if (this.agentTyping) html += '<span class="typing"><span></span><span></span><span></span></span>';
1107
- const sugg = this.suggestions;
1296
+ const sugg = this.chipLabels;
1108
1297
  if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
1109
1298
  html += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
1110
1299
  }
1300
+ if (this.state === "error" && this.lastError) {
1301
+ html += `<div class="oc-err"><span class="oc-err-t">${escapeHtml(this.lastError)}</span><button type="button" class="oc-err-retry">Try again</button></div>`;
1302
+ }
1303
+ if (this.state === "ended") {
1304
+ html += `<div class="oc-ended"><span class="oc-ended-t">Your conversation has ended.</span><button type="button" class="oc-newmsg">New message</button></div>`;
1305
+ }
1306
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
1307
+ html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
1308
+ }
1111
1309
  body.innerHTML = html;
1112
1310
  this.hydrateAttachmentImages();
1113
1311
  this.bindChips();
1114
1312
  this.renderedCount = this.messages.length;
1115
1313
  this.renderedBotFinal = botFinal;
1314
+ this.streamId = null;
1315
+ this.streamEl = null;
1316
+ if (last && last.role === "assistant" && last.streaming) {
1317
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
1318
+ this.streamEl = mds[mds.length - 1] ?? null;
1319
+ if (this.streamEl) this.streamId = last.id;
1320
+ }
1116
1321
  if (!this.uiOpen) {
1117
- if (added > 0) this.unseen += added;
1322
+ if (added > 0) {
1323
+ this.unseen += added;
1324
+ this.maybeChime();
1325
+ }
1118
1326
  this.updateClasses();
1119
1327
  return;
1120
1328
  }
@@ -1122,7 +1330,10 @@ var OyehelloChatElement = class extends HTMLElement {
1122
1330
  else if (!this.away) this.scrollToBottom();
1123
1331
  else {
1124
1332
  body.scrollTop = prevTop;
1125
- if (added > 0) this.unseen += added;
1333
+ if (added > 0) {
1334
+ this.unseen += added;
1335
+ this.maybeChime();
1336
+ }
1126
1337
  }
1127
1338
  this.updateClasses();
1128
1339
  }
@@ -1173,6 +1384,7 @@ var OyehelloChatElement = class extends HTMLElement {
1173
1384
  }
1174
1385
  this.away = false;
1175
1386
  this.emojiOpen = false;
1387
+ this.serverSuggestions = [];
1176
1388
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1177
1389
  this.textareaEl.value = "";
1178
1390
  this.textareaEl.style.height = "auto";
@@ -1181,16 +1393,70 @@ var OyehelloChatElement = class extends HTMLElement {
1181
1393
  sendChip(text) {
1182
1394
  this.away = false;
1183
1395
  this.emojiOpen = false;
1396
+ this.serverSuggestions = [];
1184
1397
  void this.client?.sendMessage(text);
1185
1398
  this.updateClasses();
1186
1399
  }
1187
1400
  bindChips() {
1188
- const sugg = this.suggestions;
1401
+ const sugg = this.chipLabels;
1189
1402
  this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
1190
1403
  const i = Number(btn.getAttribute("data-chip"));
1191
1404
  const text = sugg[i];
1192
1405
  if (text) btn.addEventListener("click", () => this.sendChip(text));
1193
1406
  });
1407
+ this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1408
+ void this.client?.start();
1409
+ });
1410
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
1411
+ }
1412
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
1413
+ startFresh() {
1414
+ this.serverSuggestions = null;
1415
+ this.receivedAt.clear();
1416
+ this.mdCache.clear();
1417
+ this.streamId = null;
1418
+ this.streamEl = null;
1419
+ this.renderedCount = 0;
1420
+ this.renderedBotFinal = 0;
1421
+ this.away = false;
1422
+ this.unseen = 0;
1423
+ this.client?.reset();
1424
+ this.started = true;
1425
+ void this.client?.start();
1426
+ setTimeout(() => this.textareaEl?.focus(), 60);
1427
+ }
1428
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
1429
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
1430
+ // or the browser hasn't allowed audio yet.
1431
+ maybeChime() {
1432
+ const last = this.messages[this.messages.length - 1];
1433
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
1434
+ }
1435
+ playChime() {
1436
+ if (this.getAttribute("sound") === "off") return;
1437
+ try {
1438
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
1439
+ if (!Ctx) return;
1440
+ if (!this.audioCtx) this.audioCtx = new Ctx();
1441
+ const ctx = this.audioCtx;
1442
+ if (ctx.state === "suspended") void ctx.resume();
1443
+ const now = ctx.currentTime;
1444
+ [880, 1174.66].forEach((freq, i) => {
1445
+ const osc = ctx.createOscillator();
1446
+ const gain = ctx.createGain();
1447
+ osc.type = "sine";
1448
+ osc.frequency.value = freq;
1449
+ const t = now + i * 0.11;
1450
+ gain.gain.setValueAtTime(1e-4, t);
1451
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
1452
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
1453
+ osc.connect(gain);
1454
+ gain.connect(ctx.destination);
1455
+ osc.start(t);
1456
+ osc.stop(t + 0.2);
1457
+ });
1458
+ } catch {
1459
+ }
1194
1460
  }
1195
1461
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1196
1462
  toggleEmoji() {