@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.
@@ -15,6 +15,9 @@ var OyehelloChat = (function (exports) {
15
15
  meta;
16
16
  };
17
17
 
18
+ // src/protocol.ts
19
+ var CHAT_PROTOCOL_VERSION = 1;
20
+
18
21
  // src/transport.ts
19
22
  async function asError(res, fallbackCode) {
20
23
  let body = {};
@@ -25,9 +28,10 @@ var OyehelloChat = (function (exports) {
25
28
  return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
26
29
  }
27
30
  async function openSession(opts) {
28
- const body = { agentId: opts.agentId };
31
+ const body = { agentId: opts.agentId, protocol: CHAT_PROTOCOL_VERSION };
29
32
  if (opts.user) body.user = opts.user;
30
33
  if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
34
+ if (opts.fingerprint) body.fingerprint = opts.fingerprint;
31
35
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
32
36
  method: "POST",
33
37
  credentials: "omit",
@@ -37,6 +41,15 @@ var OyehelloChat = (function (exports) {
37
41
  if (!res.ok) throw await asError(res);
38
42
  return await res.json();
39
43
  }
44
+ async function getWidgetConfig(opts) {
45
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/widget-config?agentId=${encodeURIComponent(opts.agentId)}`, {
46
+ method: "GET",
47
+ credentials: "omit",
48
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
49
+ });
50
+ if (!res.ok) throw await asError(res);
51
+ return await res.json();
52
+ }
40
53
  async function postMessage(opts) {
41
54
  const body = { text: opts.text };
42
55
  if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
@@ -102,6 +115,50 @@ var OyehelloChat = (function (exports) {
102
115
  if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
103
116
  }
104
117
 
118
+ // src/fingerprint.ts
119
+ async function sha256Hex(input) {
120
+ const g = globalThis;
121
+ try {
122
+ if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
123
+ const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
124
+ return Array.from(new Uint8Array(buf)).map((b2) => b2.toString(16).padStart(2, "0")).join("").slice(0, 32);
125
+ }
126
+ } catch {
127
+ }
128
+ let h = 5381;
129
+ for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
130
+ return h.toString(16).padStart(8, "0");
131
+ }
132
+ async function computeDeviceFingerprint() {
133
+ try {
134
+ const nav = typeof navigator !== "undefined" ? navigator : null;
135
+ if (!nav) return null;
136
+ const scr = typeof screen !== "undefined" ? screen : null;
137
+ const tz = (() => {
138
+ try {
139
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
140
+ } catch {
141
+ return "";
142
+ }
143
+ })();
144
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
145
+ const signals = [
146
+ nav.userAgent,
147
+ Array.isArray(nav.languages) ? nav.languages.join(",") : "",
148
+ nav.language,
149
+ nav.platform,
150
+ nav.hardwareConcurrency,
151
+ nav.deviceMemory,
152
+ scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
153
+ dpr,
154
+ tz
155
+ ].map((v2) => String(v2 ?? "")).join("|");
156
+ return await sha256Hex(signals);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
105
162
  // src/client.ts
106
163
  var seq = 0;
107
164
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -122,6 +179,8 @@ var OyehelloChat = (function (exports) {
122
179
  let reconnectTimer = null;
123
180
  let reconnectAttempts = 0;
124
181
  let closed = false;
182
+ let warnedProtocol = false;
183
+ let deviceFingerprint;
125
184
  const toolHandlers = /* @__PURE__ */ new Map();
126
185
  const clearReconnect = () => {
127
186
  if (reconnectTimer) {
@@ -214,6 +273,14 @@ var OyehelloChat = (function (exports) {
214
273
  }
215
274
  } else if (ev.type === "agent_typing") {
216
275
  emit({ type: "agentTyping", active: !!ev.active });
276
+ } else if (ev.type === "suggestions") {
277
+ emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
278
+ } else if (ev.type === "done") {
279
+ closed = true;
280
+ clearReconnect();
281
+ es?.close();
282
+ es = null;
283
+ setState("ended");
217
284
  }
218
285
  };
219
286
  const openStream = async () => {
@@ -256,7 +323,15 @@ var OyehelloChat = (function (exports) {
256
323
  }, delay);
257
324
  };
258
325
  const open = async () => {
259
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
326
+ if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
327
+ 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 });
328
+ if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
329
+ warnedProtocol = true;
330
+ try {
331
+ console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
332
+ } catch {
333
+ }
334
+ }
260
335
  emit({ type: "agent", agent: session.agent });
261
336
  await openStream();
262
337
  };
@@ -281,6 +356,9 @@ var OyehelloChat = (function (exports) {
281
356
  if (!session) throw new Error("call start() before getAttachmentUrl");
282
357
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
283
358
  },
359
+ getWidgetConfig() {
360
+ return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
361
+ },
284
362
  async start() {
285
363
  if (state === "opening" || state === "ready") return;
286
364
  closed = false;
@@ -344,6 +422,20 @@ var OyehelloChat = (function (exports) {
344
422
  es?.close();
345
423
  es = null;
346
424
  setState("ended");
425
+ },
426
+ reset() {
427
+ es?.close();
428
+ es = null;
429
+ clearReconnect();
430
+ session = null;
431
+ streamingId = null;
432
+ handoffState = "ai";
433
+ messages.length = 0;
434
+ seenTurnIds.clear();
435
+ seenCallIds.clear();
436
+ emit({ type: "messages", messages: [] });
437
+ emit({ type: "handoff", state: "ai" });
438
+ setState("idle");
347
439
  }
348
440
  };
349
441
  }
@@ -1972,8 +2064,8 @@ Please report this to https://github.com/markedjs/marked.`, e) {
1972
2064
  DOMPurify.isSupported = false;
1973
2065
  return DOMPurify;
1974
2066
  }
1975
- let document = window2.document;
1976
- const originalDocument = document;
2067
+ let document2 = window2.document;
2068
+ const originalDocument = document2;
1977
2069
  const currentScript = originalDocument.currentScript;
1978
2070
  window2.DocumentFragment;
1979
2071
  const HTMLTemplateElement = window2.HTMLTemplateElement, Node = window2.Node, Element = window2.Element, NodeFilter = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
@@ -1999,9 +2091,9 @@ Please report this to https://github.com/markedjs/marked.`, e) {
1999
2091
  return getNodeName ? getNodeName(node) : node.nodeName;
2000
2092
  };
2001
2093
  if (typeof HTMLTemplateElement === "function") {
2002
- const template = document.createElement("template");
2094
+ const template = document2.createElement("template");
2003
2095
  if (template.content && template.content.ownerDocument) {
2004
- document = template.content.ownerDocument;
2096
+ document2 = template.content.ownerDocument;
2005
2097
  }
2006
2098
  }
2007
2099
  let trustedTypesPolicy;
@@ -2039,7 +2131,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
2039
2131
  }
2040
2132
  return defaultTrustedTypesPolicy;
2041
2133
  };
2042
- const _document = document, implementation = _document.implementation, createNodeIterator = _document.createNodeIterator, createDocumentFragment = _document.createDocumentFragment, getElementsByTagName = _document.getElementsByTagName;
2134
+ const _document = document2, implementation = _document.implementation, createNodeIterator = _document.createNodeIterator, createDocumentFragment = _document.createDocumentFragment, getElementsByTagName = _document.getElementsByTagName;
2043
2135
  const importNode = originalDocument.importNode;
2044
2136
  let hooks = _createHooksMap();
2045
2137
  DOMPurify.isSupported = typeof entries === "function" && typeof getParentNode === "function" && implementation && implementation.createHTMLDocument !== void 0;
@@ -2164,7 +2256,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
2164
2256
  const DEFAULT_PARSER_MEDIA_TYPE = "text/html";
2165
2257
  let transformCaseFunc = null;
2166
2258
  let CONFIG = null;
2167
- const formElement = document.createElement("form");
2259
+ const formElement = document2.createElement("form");
2168
2260
  const isRegexOrFunction = function isRegexOrFunction2(testValue) {
2169
2261
  return testValue instanceof RegExp || testValue instanceof Function;
2170
2262
  };
@@ -2592,7 +2684,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
2592
2684
  }
2593
2685
  const body = doc.body || doc.documentElement;
2594
2686
  if (dirty && leadingWhitespace) {
2595
- body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null);
2687
+ body.insertBefore(document2.createTextNode(leadingWhitespace), body.childNodes[0] || null);
2596
2688
  }
2597
2689
  if (NAMESPACE === HTML_NAMESPACE) {
2598
2690
  return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? "html" : "body")[0];
@@ -2827,7 +2919,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
2827
2919
  if (_isPatchLinkageAttribute(lcName, lcTag)) {
2828
2920
  return false;
2829
2921
  }
2830
- if (SANITIZE_DOM && (lcName === "id" || lcName === "name") && (value in document || value in formElement)) {
2922
+ if (SANITIZE_DOM && (lcName === "id" || lcName === "name") && (value in document2 || value in formElement)) {
2831
2923
  return false;
2832
2924
  }
2833
2925
  const nameIsPermitted = ALLOWED_ATTR2[lcName] || EXTRA_ELEMENT_HANDLING.attributeCheck instanceof Function && EXTRA_ELEMENT_HANDLING.attributeCheck(lcName, lcTag);
@@ -3407,8 +3499,17 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3407
3499
  .i-expand { opacity: 1; } .i-shrink { opacity: 0; }
3408
3500
  .oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
3409
3501
 
3410
- .err { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: #fff; background: var(--oc-danger); }
3411
- .err .retry { padding: 3px 10px; font-size: 11px; background: rgba(255,255,255,0.2); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
3502
+ /* Inline connection error, shown inside the chat window (no banner) */
3503
+ .oc-err { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px; text-align: center; }
3504
+ .oc-err-t { font-size: 13px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
3505
+ .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); }
3506
+ .oc-err-retry:hover { background: var(--oc-tint-3); }
3507
+
3508
+ /* Session-ended state, inside the chat window */
3509
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
3510
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
3511
+ .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); }
3512
+ .oc-newmsg:hover { filter: brightness(1.08); }
3412
3513
 
3413
3514
  /* \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 */
3414
3515
  .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; }
@@ -3676,6 +3777,24 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3676
3777
  renderedCount = 0;
3677
3778
  renderedBotFinal = 0;
3678
3779
  timers = [];
3780
+ // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
3781
+ // widget prefers these over the static `suggestions` attribute.
3782
+ serverSuggestions = null;
3783
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
3784
+ // that element in place instead of rebuilding the whole message list on every token.
3785
+ streamId = null;
3786
+ streamEl = null;
3787
+ audioCtx = null;
3788
+ teaserText = "Need a hand? Ask me anything.";
3789
+ teaserShownOnce = false;
3790
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
3791
+ // composer is disabled (opening a session is refused server-side anyway).
3792
+ offline = false;
3793
+ offlineMessage = "We're offline right now. Please check back during business hours.";
3794
+ configLoaded = false;
3795
+ // widget-config resolved (so we know online/offline before starting)
3796
+ idleTimer = null;
3797
+ idleReset = null;
3679
3798
  // ── shadow refs ──
3680
3799
  oc;
3681
3800
  bodyEl;
@@ -3683,7 +3802,6 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3683
3802
  fileEl;
3684
3803
  titleEl;
3685
3804
  statusEl;
3686
- errEl;
3687
3805
  emojiListEl;
3688
3806
  emojiSearchEl;
3689
3807
  connectedCallback() {
@@ -3695,6 +3813,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3695
3813
  this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
3696
3814
  return;
3697
3815
  }
3816
+ this.teaserText = this.getAttribute("teaser") || this.teaserText;
3698
3817
  this.client = createChatClient({ apiKey, agentId, baseUrl });
3699
3818
  if (Object.keys(this.pendingTools).length > 0) {
3700
3819
  this.client.registerTools(this.pendingTools);
@@ -3706,6 +3825,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3706
3825
  this.updateHeader();
3707
3826
  this.updateStatus();
3708
3827
  this.updateClasses();
3828
+ void this.loadWidgetConfig();
3709
3829
  let wasOpen = false;
3710
3830
  try {
3711
3831
  wasOpen = sessionStorage.getItem(this.key("open")) === "1";
@@ -3719,24 +3839,93 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3719
3839
  this.timers.push(setTimeout(() => {
3720
3840
  this.oc?.classList.remove("ring");
3721
3841
  }, 6e3));
3722
- const teaserText = this.getAttribute("teaser");
3842
+ this.armIdleTeaser();
3843
+ }
3844
+ // Show the teaser once the visitor has been idle (no page interaction) for 10s — unless the panel
3845
+ // is already open or the teaser was dismissed this session. Fires at most once.
3846
+ armIdleTeaser() {
3723
3847
  let dismissed = false;
3724
3848
  try {
3725
3849
  dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
3726
3850
  } catch {
3727
3851
  }
3728
- if (teaserText && !dismissed) {
3729
- this.timers.push(setTimeout(() => {
3730
- if (!this.uiOpen) {
3852
+ if (dismissed) return;
3853
+ const reset = () => {
3854
+ if (this.idleTimer) clearTimeout(this.idleTimer);
3855
+ this.idleTimer = setTimeout(() => {
3856
+ if (!this.uiOpen && !this.teaserShownOnce) {
3731
3857
  this.teaserActive = true;
3858
+ this.teaserShownOnce = true;
3732
3859
  this.updateClasses();
3860
+ this.teardownIdle();
3733
3861
  }
3734
- }, 1400));
3862
+ }, 1e4);
3863
+ };
3864
+ this.idleReset = reset;
3865
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.addEventListener(ev, reset, { passive: true });
3866
+ reset();
3867
+ }
3868
+ teardownIdle() {
3869
+ if (this.idleTimer) {
3870
+ clearTimeout(this.idleTimer);
3871
+ this.idleTimer = null;
3735
3872
  }
3873
+ if (this.idleReset) {
3874
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.removeEventListener(ev, this.idleReset);
3875
+ this.idleReset = null;
3876
+ }
3877
+ }
3878
+ async loadWidgetConfig() {
3879
+ try {
3880
+ const cfg = await this.client.getWidgetConfig();
3881
+ if (!this.getAttribute("teaser") && cfg.teaser) {
3882
+ this.teaserText = cfg.teaser;
3883
+ this.applyTeaserText();
3884
+ }
3885
+ if (cfg.brand) {
3886
+ if (cfg.brand.accentColor) this.style.setProperty("--oc-accent", cfg.brand.accentColor);
3887
+ if (cfg.brand.name && !this.getAttribute("title")) {
3888
+ this.agentName = cfg.brand.name;
3889
+ this.updateHeader();
3890
+ }
3891
+ if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
3892
+ }
3893
+ if (cfg.offline) {
3894
+ this.offline = true;
3895
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
3896
+ this.renderMessages();
3897
+ this.updateStatus();
3898
+ }
3899
+ } catch {
3900
+ } finally {
3901
+ this.configLoaded = true;
3902
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
3903
+ this.started = true;
3904
+ void this.client.start();
3905
+ }
3906
+ }
3907
+ }
3908
+ applyTeaserText() {
3909
+ const el = this.shadowRoot?.querySelector(".teaser-btn span:last-child");
3910
+ if (el) el.textContent = this.teaserText;
3911
+ }
3912
+ applyBrandLogo(url) {
3913
+ const set = (host) => {
3914
+ if (!host) return;
3915
+ host.textContent = "";
3916
+ const img = document.createElement("img");
3917
+ img.src = url;
3918
+ img.alt = "";
3919
+ img.style.cssText = "width:100%;height:100%;object-fit:contain";
3920
+ host.appendChild(img);
3921
+ };
3922
+ set(this.shadowRoot?.querySelector(".hd .logo"));
3923
+ set(this.shadowRoot?.querySelector(".teaser-btn .logo"));
3736
3924
  }
3737
3925
  disconnectedCallback() {
3738
3926
  this.off?.();
3739
3927
  void this.client?.end();
3928
+ this.teardownIdle();
3740
3929
  for (const t of this.timers) clearTimeout(t);
3741
3930
  for (const url of this.imagePreviewCache.values()) {
3742
3931
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -3750,18 +3939,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3750
3939
  key(k2) {
3751
3940
  return `oyehello-chat:${k2}:${this.getAttribute("agent-id") ?? ""}`;
3752
3941
  }
3753
- get suggestions() {
3942
+ // Static fallback chips from the `suggestions` attribute (for embedders without flow-driven ones).
3943
+ get attrSuggestions() {
3754
3944
  return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3755
3945
  }
3946
+ // Effective chips: server (flow) suggestions once received, else the static attribute fallback.
3947
+ get chipLabels() {
3948
+ return this.serverSuggestions ?? this.attrSuggestions;
3949
+ }
3756
3950
  // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
3757
3951
  renderShell() {
3758
3952
  const root = this.shadowRoot;
3759
- const hasTeaser = !!this.getAttribute("teaser");
3760
3953
  const hasPrivacy = !!this.getAttribute("privacy-url");
3761
- const teaserText = this.getAttribute("teaser") ?? "";
3954
+ const teaserText = this.teaserText;
3762
3955
  root.innerHTML = `
3763
3956
  <style>${STYLES}</style>
3764
- <div class="oc${hasTeaser ? " has-teaser" : ""}${hasPrivacy ? " has-privacy" : ""}">
3957
+ <div class="oc has-teaser${hasPrivacy ? " has-privacy" : ""}">
3765
3958
  <div class="panel" role="dialog" aria-label="Chat">
3766
3959
  <div class="hd">
3767
3960
  <span class="hd-grid"></span>
@@ -3770,7 +3963,6 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3770
3963
  <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>
3771
3964
  <button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
3772
3965
  </div>
3773
- <div class="err" hidden></div>
3774
3966
  <div class="body"></div>
3775
3967
  <span class="emoji-scrim"></span>
3776
3968
  <div class="emoji-panel">
@@ -3814,7 +4006,6 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3814
4006
  this.fileEl = root.querySelector('input[type="file"]');
3815
4007
  this.titleEl = root.querySelector(".hd-title");
3816
4008
  this.statusEl = root.querySelector(".hd-status .t");
3817
- this.errEl = root.querySelector(".err");
3818
4009
  this.emojiListEl = root.querySelector(".emoji-list");
3819
4010
  this.emojiSearchEl = root.querySelector(".emoji-search input");
3820
4011
  root.querySelector(".launcher").addEventListener("click", () => this.toggle());
@@ -3895,11 +4086,12 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3895
4086
  this.uiOpen = true;
3896
4087
  this.teaserActive = false;
3897
4088
  this.unseen = 0;
4089
+ this.teardownIdle();
3898
4090
  try {
3899
4091
  sessionStorage.setItem(this.key("open"), "1");
3900
4092
  } catch {
3901
4093
  }
3902
- if (!this.started && this.client) {
4094
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
3903
4095
  this.started = true;
3904
4096
  void this.client.start();
3905
4097
  }
@@ -3937,6 +4129,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3937
4129
  this.updateHeader();
3938
4130
  } else if (e.type === "handoff") this.handoffState = e.state;
3939
4131
  else if (e.type === "agentTyping") this.agentTyping = e.active;
4132
+ else if (e.type === "suggestions") this.serverSuggestions = e.items;
3940
4133
  else if (e.type === "error") this.lastError = e.error.message;
3941
4134
  this.updateStatus();
3942
4135
  this.renderMessages();
@@ -3946,26 +4139,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3946
4139
  }
3947
4140
  updateStatus() {
3948
4141
  if (!this.textareaEl) return;
3949
- const disabled = this.state !== "ready";
4142
+ const disabled = this.state !== "ready" || this.offline;
3950
4143
  this.textareaEl.disabled = disabled;
3951
4144
  const handoff = HANDOFF_STATUS[this.handoffState];
3952
- const text2 = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4145
+ const text2 = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
3953
4146
  if (this.statusEl) this.statusEl.textContent = text2;
3954
- if (this.state === "error" && this.lastError) {
3955
- this.errEl.hidden = false;
3956
- this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
3957
- this.errEl.querySelector(".retry").addEventListener("click", () => {
3958
- void this.client?.start();
3959
- });
3960
- } else {
3961
- this.errEl.hidden = true;
3962
- this.errEl.innerHTML = "";
3963
- }
3964
4147
  }
3965
4148
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
3966
4149
  renderMessages() {
3967
4150
  if (!this.bodyEl) return;
3968
4151
  const body = this.bodyEl;
4152
+ const last = this.messages[this.messages.length - 1];
4153
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
4154
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
4155
+ if (!this.away) this.scrollToBottom();
4156
+ return;
4157
+ }
3969
4158
  const prevTop = body.scrollTop;
3970
4159
  const added = this.messages.length - this.renderedCount;
3971
4160
  const botFinal = this.messages.filter((m2) => m2.role === "assistant" && !m2.streaming).length;
@@ -3978,17 +4167,36 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3978
4167
  })();
3979
4168
  let html2 = this.messages.map((m2, i) => this.turnHtml(m2, i === lastBot && !this.agentTyping)).join("");
3980
4169
  if (this.agentTyping) html2 += '<span class="typing"><span></span><span></span><span></span></span>';
3981
- const sugg = this.suggestions;
4170
+ const sugg = this.chipLabels;
3982
4171
  if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
3983
4172
  html2 += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
3984
4173
  }
4174
+ if (this.state === "error" && this.lastError) {
4175
+ html2 += `<div class="oc-err"><span class="oc-err-t">${escapeHtml(this.lastError)}</span><button type="button" class="oc-err-retry">Try again</button></div>`;
4176
+ }
4177
+ if (this.state === "ended") {
4178
+ html2 += `<div class="oc-ended"><span class="oc-ended-t">Your conversation has ended.</span><button type="button" class="oc-newmsg">New message</button></div>`;
4179
+ }
4180
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
4181
+ html2 += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
4182
+ }
3985
4183
  body.innerHTML = html2;
3986
4184
  this.hydrateAttachmentImages();
3987
4185
  this.bindChips();
3988
4186
  this.renderedCount = this.messages.length;
3989
4187
  this.renderedBotFinal = botFinal;
4188
+ this.streamId = null;
4189
+ this.streamEl = null;
4190
+ if (last && last.role === "assistant" && last.streaming) {
4191
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
4192
+ this.streamEl = mds[mds.length - 1] ?? null;
4193
+ if (this.streamEl) this.streamId = last.id;
4194
+ }
3990
4195
  if (!this.uiOpen) {
3991
- if (added > 0) this.unseen += added;
4196
+ if (added > 0) {
4197
+ this.unseen += added;
4198
+ this.maybeChime();
4199
+ }
3992
4200
  this.updateClasses();
3993
4201
  return;
3994
4202
  }
@@ -3996,7 +4204,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3996
4204
  else if (!this.away) this.scrollToBottom();
3997
4205
  else {
3998
4206
  body.scrollTop = prevTop;
3999
- if (added > 0) this.unseen += added;
4207
+ if (added > 0) {
4208
+ this.unseen += added;
4209
+ this.maybeChime();
4210
+ }
4000
4211
  }
4001
4212
  this.updateClasses();
4002
4213
  }
@@ -4047,6 +4258,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4047
4258
  }
4048
4259
  this.away = false;
4049
4260
  this.emojiOpen = false;
4261
+ this.serverSuggestions = [];
4050
4262
  void this.client?.sendMessage(text2, files.length > 0 ? files : void 0);
4051
4263
  this.textareaEl.value = "";
4052
4264
  this.textareaEl.style.height = "auto";
@@ -4055,16 +4267,70 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4055
4267
  sendChip(text2) {
4056
4268
  this.away = false;
4057
4269
  this.emojiOpen = false;
4270
+ this.serverSuggestions = [];
4058
4271
  void this.client?.sendMessage(text2);
4059
4272
  this.updateClasses();
4060
4273
  }
4061
4274
  bindChips() {
4062
- const sugg = this.suggestions;
4275
+ const sugg = this.chipLabels;
4063
4276
  this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
4064
4277
  const i = Number(btn.getAttribute("data-chip"));
4065
4278
  const text2 = sugg[i];
4066
4279
  if (text2) btn.addEventListener("click", () => this.sendChip(text2));
4067
4280
  });
4281
+ this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
4282
+ void this.client?.start();
4283
+ });
4284
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
4285
+ }
4286
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
4287
+ startFresh() {
4288
+ this.serverSuggestions = null;
4289
+ this.receivedAt.clear();
4290
+ this.mdCache.clear();
4291
+ this.streamId = null;
4292
+ this.streamEl = null;
4293
+ this.renderedCount = 0;
4294
+ this.renderedBotFinal = 0;
4295
+ this.away = false;
4296
+ this.unseen = 0;
4297
+ this.client?.reset();
4298
+ this.started = true;
4299
+ void this.client?.start();
4300
+ setTimeout(() => this.textareaEl?.focus(), 60);
4301
+ }
4302
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
4303
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
4304
+ // or the browser hasn't allowed audio yet.
4305
+ maybeChime() {
4306
+ const last = this.messages[this.messages.length - 1];
4307
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
4308
+ }
4309
+ playChime() {
4310
+ if (this.getAttribute("sound") === "off") return;
4311
+ try {
4312
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
4313
+ if (!Ctx) return;
4314
+ if (!this.audioCtx) this.audioCtx = new Ctx();
4315
+ const ctx = this.audioCtx;
4316
+ if (ctx.state === "suspended") void ctx.resume();
4317
+ const now = ctx.currentTime;
4318
+ [880, 1174.66].forEach((freq, i) => {
4319
+ const osc = ctx.createOscillator();
4320
+ const gain = ctx.createGain();
4321
+ osc.type = "sine";
4322
+ osc.frequency.value = freq;
4323
+ const t = now + i * 0.11;
4324
+ gain.gain.setValueAtTime(1e-4, t);
4325
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
4326
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
4327
+ osc.connect(gain);
4328
+ gain.connect(ctx.destination);
4329
+ osc.start(t);
4330
+ osc.stop(t + 0.2);
4331
+ });
4332
+ } catch {
4333
+ }
4068
4334
  }
4069
4335
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
4070
4336
  toggleEmoji() {