@oyehello/chat 0.4.0 → 0.5.1

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/dist/index.js CHANGED
@@ -15,6 +15,9 @@ var ChatApiError = class extends Error {
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 @@ async function asError(res, fallbackCode) {
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",
@@ -57,6 +61,15 @@ async function postMessage(opts) {
57
61
  });
58
62
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
59
63
  }
64
+ async function postPageViews(opts) {
65
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
66
+ method: "POST",
67
+ credentials: "omit",
68
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
69
+ body: JSON.stringify({ views: opts.views })
70
+ });
71
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
72
+ }
60
73
  async function endSession(opts) {
61
74
  await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
62
75
  method: "POST",
@@ -74,6 +87,15 @@ async function postToolResult(opts) {
74
87
  }).catch(() => {
75
88
  });
76
89
  }
90
+ async function refreshSessionToken(opts) {
91
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/refresh-token`, {
92
+ method: "POST",
93
+ credentials: "omit",
94
+ headers: { Authorization: `Bearer ${opts.sessionToken}` }
95
+ });
96
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
97
+ return await res.json();
98
+ }
77
99
  async function mintStreamTicket(opts) {
78
100
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
79
101
  method: "POST",
@@ -86,15 +108,18 @@ async function mintStreamTicket(opts) {
86
108
  function streamUrl(baseUrl, sessionId, ticket) {
87
109
  return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
88
110
  }
89
- async function presignAttachment(opts) {
90
- const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachments`, {
111
+ async function uploadAttachment(opts) {
112
+ const form = new FormData();
113
+ form.append("file", opts.file, opts.file.name);
114
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/upload`, {
91
115
  method: "POST",
92
116
  credentials: "omit",
93
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
94
- body: JSON.stringify({ contentType: opts.contentType })
117
+ headers: { Authorization: `Bearer ${opts.sessionToken}` },
118
+ body: form
95
119
  });
96
120
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
97
- return await res.json();
121
+ const j = await res.json();
122
+ return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
98
123
  }
99
124
  async function getAttachmentUrl(opts) {
100
125
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
@@ -106,9 +131,178 @@ async function getAttachmentUrl(opts) {
106
131
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
107
132
  return (await res.json()).url;
108
133
  }
109
- async function uploadToPresigned(uploadUrl, file, fetchImpl) {
110
- const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
111
- if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
134
+
135
+ // src/fingerprint.ts
136
+ async function sha256Hex(input) {
137
+ const g = globalThis;
138
+ try {
139
+ if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
140
+ const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
141
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
142
+ }
143
+ } catch {
144
+ }
145
+ let h = 5381;
146
+ for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
147
+ return h.toString(16).padStart(8, "0");
148
+ }
149
+ async function computeDeviceFingerprint() {
150
+ try {
151
+ const nav = typeof navigator !== "undefined" ? navigator : null;
152
+ if (!nav) return null;
153
+ const scr = typeof screen !== "undefined" ? screen : null;
154
+ const tz = (() => {
155
+ try {
156
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
157
+ } catch {
158
+ return "";
159
+ }
160
+ })();
161
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
162
+ const signals = [
163
+ nav.userAgent,
164
+ Array.isArray(nav.languages) ? nav.languages.join(",") : "",
165
+ nav.language,
166
+ nav.platform,
167
+ nav.hardwareConcurrency,
168
+ nav.deviceMemory,
169
+ scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
170
+ dpr,
171
+ tz
172
+ ].map((v) => String(v ?? "")).join("|");
173
+ return await sha256Hex(signals);
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ // src/journey.ts
180
+ var MAX_BUFFER = 25;
181
+ var storageKey = (agentId) => `oyehello-chat:journey:${agentId}`;
182
+ var store = () => {
183
+ try {
184
+ return globalThis.sessionStorage ?? null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ };
189
+ function load(agentId) {
190
+ const s = store();
191
+ if (!s) return { views: [], flushed: 0 };
192
+ try {
193
+ const raw = s.getItem(storageKey(agentId));
194
+ if (!raw) return { views: [], flushed: 0 };
195
+ const parsed = JSON.parse(raw);
196
+ const views = Array.isArray(parsed?.views) ? parsed.views.filter((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
197
+ const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
198
+ return { views, flushed: Math.min(flushedRaw, views.length) };
199
+ } catch {
200
+ return { views: [], flushed: 0 };
201
+ }
202
+ }
203
+ function save(agentId, data) {
204
+ const s = store();
205
+ if (!s) return;
206
+ try {
207
+ s.setItem(storageKey(agentId), JSON.stringify(data));
208
+ } catch {
209
+ }
210
+ }
211
+ var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
212
+ function recordView(agentId, path) {
213
+ try {
214
+ const p = sanitizePath(path);
215
+ const data = load(agentId);
216
+ const last = data.views[data.views.length - 1];
217
+ if (last && last.path === p) return;
218
+ data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
219
+ if (data.views.length > MAX_BUFFER) {
220
+ const drop = data.views.length - MAX_BUFFER;
221
+ data.views = data.views.slice(drop);
222
+ data.flushed = Math.max(0, data.flushed - drop);
223
+ }
224
+ save(agentId, data);
225
+ } catch {
226
+ }
227
+ }
228
+ function unsentViews(agentId) {
229
+ try {
230
+ const data = load(agentId);
231
+ return data.views.slice(data.flushed);
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+ function markFlushed(agentId, count) {
237
+ if (count <= 0) return;
238
+ try {
239
+ const data = load(agentId);
240
+ data.flushed = Math.min(data.views.length, data.flushed + count);
241
+ save(agentId, data);
242
+ } catch {
243
+ }
244
+ }
245
+ function installSpaHooks(onNav) {
246
+ const originalPush = history.pushState;
247
+ const originalReplace = history.replaceState;
248
+ let pushInstalled = false;
249
+ let replaceInstalled = false;
250
+ try {
251
+ history.pushState = function(...args) {
252
+ const ret = originalPush.apply(this, args);
253
+ try {
254
+ onNav();
255
+ } catch {
256
+ }
257
+ return ret;
258
+ };
259
+ pushInstalled = true;
260
+ } catch {
261
+ }
262
+ try {
263
+ history.replaceState = function(...args) {
264
+ const ret = originalReplace.apply(this, args);
265
+ try {
266
+ onNav();
267
+ } catch {
268
+ }
269
+ return ret;
270
+ };
271
+ replaceInstalled = true;
272
+ } catch {
273
+ }
274
+ const onPopState = () => {
275
+ try {
276
+ onNav();
277
+ } catch {
278
+ }
279
+ };
280
+ let popstateInstalled = false;
281
+ try {
282
+ window.addEventListener("popstate", onPopState);
283
+ popstateInstalled = true;
284
+ } catch {
285
+ }
286
+ return () => {
287
+ if (pushInstalled) {
288
+ try {
289
+ history.pushState = originalPush;
290
+ } catch {
291
+ }
292
+ }
293
+ if (replaceInstalled) {
294
+ try {
295
+ history.replaceState = originalReplace;
296
+ } catch {
297
+ }
298
+ }
299
+ if (popstateInstalled) {
300
+ try {
301
+ window.removeEventListener("popstate", onPopState);
302
+ } catch {
303
+ }
304
+ }
305
+ };
112
306
  }
113
307
 
114
308
  // src/client.ts
@@ -131,13 +325,97 @@ function createChatClient(options) {
131
325
  let reconnectTimer = null;
132
326
  let reconnectAttempts = 0;
133
327
  let closed = false;
328
+ let warnedProtocol = false;
329
+ let deviceFingerprint;
330
+ let refreshTimer = null;
134
331
  const toolHandlers = /* @__PURE__ */ new Map();
332
+ let journeyFlushChain = Promise.resolve();
333
+ let journeyFlushQueued = false;
334
+ const flushJourney = () => {
335
+ if (!session) return;
336
+ if (journeyFlushQueued) return;
337
+ journeyFlushQueued = true;
338
+ journeyFlushChain = journeyFlushChain.then(async () => {
339
+ journeyFlushQueued = false;
340
+ if (!session) return;
341
+ const pending = unsentViews(options.agentId);
342
+ if (pending.length === 0) return;
343
+ const s = session;
344
+ try {
345
+ await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
346
+ markFlushed(options.agentId, pending.length);
347
+ } catch {
348
+ }
349
+ });
350
+ };
135
351
  const clearReconnect = () => {
136
352
  if (reconnectTimer) {
137
353
  clearTimeout(reconnectTimer);
138
354
  reconnectTimer = null;
139
355
  }
140
356
  };
357
+ const clearRefresh = () => {
358
+ if (refreshTimer) {
359
+ clearTimeout(refreshTimer);
360
+ refreshTimer = null;
361
+ }
362
+ };
363
+ const scheduleRefresh = () => {
364
+ clearRefresh();
365
+ if (closed || !session) return;
366
+ const untilExpiry = Date.parse(session.expiresAt) - Date.now();
367
+ const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
368
+ refreshTimer = setTimeout(async () => {
369
+ if (closed || !session) return;
370
+ try {
371
+ const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
372
+ session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
373
+ persistSession(session);
374
+ scheduleRefresh();
375
+ } catch (err) {
376
+ const code = err?.code;
377
+ if (code === "session_ended" || code === "session_expired") clearStoredSession();
378
+ }
379
+ }, delay);
380
+ refreshTimer.unref?.();
381
+ };
382
+ const sessKey = `oyehello-chat-sess:${options.agentId}`;
383
+ const store2 = (() => {
384
+ try {
385
+ return globalThis.sessionStorage ?? null;
386
+ } catch {
387
+ return null;
388
+ }
389
+ })();
390
+ const loadStoredSession = () => {
391
+ if (!store2) return null;
392
+ try {
393
+ const raw = store2.getItem(sessKey);
394
+ if (!raw) return null;
395
+ const s = JSON.parse(raw);
396
+ if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
397
+ const exp = Date.parse(s.expiresAt);
398
+ if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
399
+ store2.removeItem(sessKey);
400
+ return null;
401
+ }
402
+ return s;
403
+ } catch {
404
+ return null;
405
+ }
406
+ };
407
+ const persistSession = (s) => {
408
+ try {
409
+ store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
410
+ } catch {
411
+ }
412
+ };
413
+ const clearStoredSession = () => {
414
+ try {
415
+ store2?.removeItem(sessKey);
416
+ } catch {
417
+ }
418
+ };
141
419
  const emit = (e) => {
142
420
  for (const l of listeners) l(e);
143
421
  };
@@ -225,6 +503,14 @@ function createChatClient(options) {
225
503
  emit({ type: "agentTyping", active: !!ev.active });
226
504
  } else if (ev.type === "suggestions") {
227
505
  emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
506
+ } else if (ev.type === "done") {
507
+ closed = true;
508
+ clearReconnect();
509
+ clearRefresh();
510
+ es?.close();
511
+ es = null;
512
+ clearStoredSession();
513
+ setState("ended");
228
514
  }
229
515
  };
230
516
  const openStream = async () => {
@@ -256,6 +542,7 @@ function createChatClient(options) {
256
542
  reconnectTimer = setTimeout(() => {
257
543
  openStream().catch((err) => {
258
544
  if (err instanceof ChatApiError && err.code === "session_expired") {
545
+ clearStoredSession();
259
546
  open().catch((e) => {
260
547
  emit({ type: "error", error: e });
261
548
  scheduleReconnect();
@@ -267,16 +554,32 @@ function createChatClient(options) {
267
554
  }, delay);
268
555
  };
269
556
  const open = async () => {
270
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
557
+ const resumed = loadStoredSession();
558
+ if (resumed) {
559
+ session = resumed;
560
+ emit({ type: "agent", agent: resumed.agent });
561
+ await openStream();
562
+ scheduleRefresh();
563
+ flushJourney();
564
+ return;
565
+ }
566
+ if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
567
+ 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 });
568
+ persistSession(session);
569
+ if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
570
+ warnedProtocol = true;
571
+ try {
572
+ console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
573
+ } catch {
574
+ }
575
+ }
271
576
  emit({ type: "agent", agent: session.agent });
272
577
  await openStream();
578
+ scheduleRefresh();
579
+ flushJourney();
273
580
  };
274
581
  const uploadFiles = async (files) => {
275
- return Promise.all(files.map(async (file) => {
276
- const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
277
- await uploadToPresigned(uploadUrl, file, fetchImpl);
278
- return { storageRef, contentType: file.type, name: file.name };
279
- }));
582
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
280
583
  };
281
584
  return {
282
585
  getMessages: () => messages.slice(),
@@ -295,6 +598,13 @@ function createChatClient(options) {
295
598
  getWidgetConfig() {
296
599
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
297
600
  },
601
+ recordPageView(path) {
602
+ try {
603
+ recordView(options.agentId, path);
604
+ flushJourney();
605
+ } catch {
606
+ }
607
+ },
298
608
  async start() {
299
609
  if (state === "opening" || state === "ready") return;
300
610
  closed = false;
@@ -340,6 +650,7 @@ function createChatClient(options) {
340
650
  } catch (err) {
341
651
  if (err instanceof ChatApiError && err.code === "session_expired") {
342
652
  try {
653
+ clearStoredSession();
343
654
  await open();
344
655
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
345
656
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
@@ -351,13 +662,38 @@ function createChatClient(options) {
351
662
  }
352
663
  }
353
664
  },
665
+ detach() {
666
+ closed = true;
667
+ clearReconnect();
668
+ clearRefresh();
669
+ es?.close();
670
+ es = null;
671
+ },
354
672
  async end() {
355
673
  closed = true;
356
674
  clearReconnect();
675
+ clearRefresh();
676
+ clearStoredSession();
357
677
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
358
678
  es?.close();
359
679
  es = null;
360
680
  setState("ended");
681
+ },
682
+ reset() {
683
+ clearStoredSession();
684
+ es?.close();
685
+ es = null;
686
+ clearReconnect();
687
+ clearRefresh();
688
+ session = null;
689
+ streamingId = null;
690
+ handoffState = "ai";
691
+ messages.length = 0;
692
+ seenTurnIds.clear();
693
+ seenCallIds.clear();
694
+ emit({ type: "messages", messages: [] });
695
+ emit({ type: "handoff", state: "ai" });
696
+ setState("idle");
361
697
  }
362
698
  };
363
699
  }
@@ -439,6 +775,10 @@ var STYLES = `
439
775
  --oc-glass-border: rgba(9,13,20,0.12);
440
776
 
441
777
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
778
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
779
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
780
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
781
+ display: block; visibility: visible; opacity: 1;
442
782
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
443
783
  -webkit-font-smoothing: antialiased;
444
784
  }
@@ -478,7 +818,7 @@ var STYLES = `
478
818
  .launcher:hover { transform: scale(1.06); }
479
819
  .launcher:active { transform: scale(0.93); }
480
820
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
481
- .oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
821
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
482
822
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
483
823
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
484
824
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -500,7 +840,7 @@ var STYLES = `
500
840
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
501
841
  }
502
842
  .oc.has-teaser .teaser { display: flex; }
503
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
843
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
504
844
  .teaser-btn {
505
845
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
506
846
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -547,6 +887,12 @@ var STYLES = `
547
887
  .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); }
548
888
  .oc-err-retry:hover { background: var(--oc-tint-3); }
549
889
 
890
+ /* Session-ended state, inside the chat window */
891
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
892
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
893
+ .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); }
894
+ .oc-newmsg:hover { filter: brightness(1.08); }
895
+
550
896
  /* \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 */
551
897
  .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; }
552
898
  .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
@@ -591,6 +937,7 @@ var STYLES = `
591
937
 
592
938
  /* \u2500\u2500 Composer \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\u2500\u2500\u2500\u2500 */
593
939
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
940
+ .composer[hidden] { display: none; }
594
941
  .field { display: flex; flex-direction: column; gap: 11px; padding: 13px 14px 11px; border-radius: 18px; border: 1px solid var(--oc-field-border); background: var(--oc-field-bg); transition: border-color .18s var(--oc-ease); }
595
942
  .oc.focus .field { border-color: var(--oc-ac); }
596
943
  .input { width: 100%; border: none; background: transparent; color: var(--oc-fg); font-size: 13.5px; line-height: 1.4; outline: none; padding: 0; resize: none; max-height: 108px; font-family: inherit; }
@@ -816,14 +1163,29 @@ var OyehelloChatElement = class extends HTMLElement {
816
1163
  // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
817
1164
  // widget prefers these over the static `suggestions` attribute.
818
1165
  serverSuggestions = null;
1166
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
1167
+ // that element in place instead of rebuilding the whole message list on every token.
1168
+ streamId = null;
1169
+ streamEl = null;
1170
+ audioCtx = null;
819
1171
  teaserText = "Need a hand? Ask me anything.";
820
1172
  teaserShownOnce = false;
1173
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
1174
+ // composer is disabled (opening a session is refused server-side anyway).
1175
+ offline = false;
1176
+ offlineMessage = "We're offline right now. Please check back during business hours.";
1177
+ configLoaded = false;
1178
+ // widget-config resolved (so we know online/offline before starting)
821
1179
  idleTimer = null;
822
1180
  idleReset = null;
1181
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1182
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
1183
+ uninstallSpaHooks = null;
823
1184
  // ── shadow refs ──
824
1185
  oc;
825
1186
  bodyEl;
826
1187
  textareaEl;
1188
+ composerEl;
827
1189
  fileEl;
828
1190
  titleEl;
829
1191
  statusEl;
@@ -845,7 +1207,16 @@ var OyehelloChatElement = class extends HTMLElement {
845
1207
  this.pendingTools = {};
846
1208
  }
847
1209
  this.off = this.client.on((e) => this.onClientEvent(e));
1210
+ recordView(agentId, location.pathname);
1211
+ try {
1212
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
1213
+ } catch {
1214
+ this.uninstallSpaHooks = null;
1215
+ }
848
1216
  this.renderShell();
1217
+ this.style.setProperty("display", "block", "important");
1218
+ this.style.setProperty("visibility", "visible", "important");
1219
+ this.style.setProperty("opacity", "1", "important");
849
1220
  this.renderMessages();
850
1221
  this.updateHeader();
851
1222
  this.updateStatus();
@@ -862,7 +1233,7 @@ var OyehelloChatElement = class extends HTMLElement {
862
1233
  }
863
1234
  this.updateClasses(true);
864
1235
  this.timers.push(setTimeout(() => {
865
- this.oc?.classList.remove("ring");
1236
+ this.oc?.classList.remove("show-ring");
866
1237
  }, 6e3));
867
1238
  this.armIdleTeaser();
868
1239
  }
@@ -915,7 +1286,19 @@ var OyehelloChatElement = class extends HTMLElement {
915
1286
  }
916
1287
  if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
917
1288
  }
1289
+ if (cfg.offline) {
1290
+ this.offline = true;
1291
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
1292
+ this.renderMessages();
1293
+ this.updateStatus();
1294
+ }
918
1295
  } catch {
1296
+ } finally {
1297
+ this.configLoaded = true;
1298
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
1299
+ this.started = true;
1300
+ void this.client.start();
1301
+ }
919
1302
  }
920
1303
  }
921
1304
  applyTeaserText() {
@@ -937,8 +1320,10 @@ var OyehelloChatElement = class extends HTMLElement {
937
1320
  }
938
1321
  disconnectedCallback() {
939
1322
  this.off?.();
940
- void this.client?.end();
1323
+ this.client?.detach();
941
1324
  this.teardownIdle();
1325
+ this.uninstallSpaHooks?.();
1326
+ this.uninstallSpaHooks = null;
942
1327
  for (const t of this.timers) clearTimeout(t);
943
1328
  for (const url of this.imagePreviewCache.values()) {
944
1329
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1016,6 +1401,7 @@ var OyehelloChatElement = class extends HTMLElement {
1016
1401
  this.oc = root.querySelector(".oc");
1017
1402
  this.bodyEl = root.querySelector(".body");
1018
1403
  this.textareaEl = root.querySelector("textarea.input");
1404
+ this.composerEl = root.querySelector(".composer");
1019
1405
  this.fileEl = root.querySelector('input[type="file"]');
1020
1406
  this.titleEl = root.querySelector(".hd-title");
1021
1407
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1074,18 +1460,22 @@ var OyehelloChatElement = class extends HTMLElement {
1074
1460
  this.shadowRoot.innerHTML = `<div style="position:fixed;right:16px;bottom:16px;color:#b91c1c;font:12px system-ui;background:#fef2f2;border:1px solid #fecaca;padding:8px 12px;border-radius:8px">${escapeHtml(msg)}</div>`;
1075
1461
  }
1076
1462
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
1463
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
1464
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
1465
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
1466
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
1077
1467
  updateClasses(initialRing = false) {
1078
1468
  if (!this.oc) return;
1079
1469
  const c = this.oc.classList;
1080
1470
  c.toggle("open", this.uiOpen);
1081
1471
  c.toggle("max", this.uiMax);
1082
1472
  c.toggle("emoji", this.emojiOpen);
1083
- c.toggle("teaser", this.teaserActive);
1473
+ c.toggle("show-teaser", this.teaserActive);
1084
1474
  c.toggle("away", this.away);
1085
1475
  c.toggle("focus", this.focused);
1086
1476
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1087
1477
  c.toggle("unread", this.unseen > 0);
1088
- if (initialRing) c.add("ring");
1478
+ if (initialRing) c.add("show-ring");
1089
1479
  const badge = this.oc.querySelector(".badge");
1090
1480
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1091
1481
  const launcher = this.oc.querySelector(".launcher");
@@ -1104,7 +1494,7 @@ var OyehelloChatElement = class extends HTMLElement {
1104
1494
  sessionStorage.setItem(this.key("open"), "1");
1105
1495
  } catch {
1106
1496
  }
1107
- if (!this.started && this.client) {
1497
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
1108
1498
  this.started = true;
1109
1499
  void this.client.start();
1110
1500
  }
@@ -1152,16 +1542,23 @@ var OyehelloChatElement = class extends HTMLElement {
1152
1542
  }
1153
1543
  updateStatus() {
1154
1544
  if (!this.textareaEl) return;
1155
- const disabled = this.state !== "ready";
1545
+ const disabled = this.state !== "ready" || this.offline;
1156
1546
  this.textareaEl.disabled = disabled;
1547
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1157
1548
  const handoff = HANDOFF_STATUS[this.handoffState];
1158
- const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1549
+ const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1159
1550
  if (this.statusEl) this.statusEl.textContent = text;
1160
1551
  }
1161
1552
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1162
1553
  renderMessages() {
1163
1554
  if (!this.bodyEl) return;
1164
1555
  const body = this.bodyEl;
1556
+ const last = this.messages[this.messages.length - 1];
1557
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
1558
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
1559
+ if (!this.away) this.scrollToBottom();
1560
+ return;
1561
+ }
1165
1562
  const prevTop = body.scrollTop;
1166
1563
  const added = this.messages.length - this.renderedCount;
1167
1564
  const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
@@ -1181,13 +1578,29 @@ var OyehelloChatElement = class extends HTMLElement {
1181
1578
  if (this.state === "error" && this.lastError) {
1182
1579
  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>`;
1183
1580
  }
1581
+ if (this.state === "ended") {
1582
+ 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>`;
1583
+ }
1584
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
1585
+ html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
1586
+ }
1184
1587
  body.innerHTML = html;
1185
1588
  this.hydrateAttachmentImages();
1186
1589
  this.bindChips();
1187
1590
  this.renderedCount = this.messages.length;
1188
1591
  this.renderedBotFinal = botFinal;
1592
+ this.streamId = null;
1593
+ this.streamEl = null;
1594
+ if (last && last.role === "assistant" && last.streaming) {
1595
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
1596
+ this.streamEl = mds[mds.length - 1] ?? null;
1597
+ if (this.streamEl) this.streamId = last.id;
1598
+ }
1189
1599
  if (!this.uiOpen) {
1190
- if (added > 0) this.unseen += added;
1600
+ if (added > 0) {
1601
+ this.unseen += added;
1602
+ this.maybeChime();
1603
+ }
1191
1604
  this.updateClasses();
1192
1605
  return;
1193
1606
  }
@@ -1195,7 +1608,10 @@ var OyehelloChatElement = class extends HTMLElement {
1195
1608
  else if (!this.away) this.scrollToBottom();
1196
1609
  else {
1197
1610
  body.scrollTop = prevTop;
1198
- if (added > 0) this.unseen += added;
1611
+ if (added > 0) {
1612
+ this.unseen += added;
1613
+ this.maybeChime();
1614
+ }
1199
1615
  }
1200
1616
  this.updateClasses();
1201
1617
  }
@@ -1246,6 +1662,7 @@ var OyehelloChatElement = class extends HTMLElement {
1246
1662
  }
1247
1663
  this.away = false;
1248
1664
  this.emojiOpen = false;
1665
+ this.serverSuggestions = [];
1249
1666
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1250
1667
  this.textareaEl.value = "";
1251
1668
  this.textareaEl.style.height = "auto";
@@ -1254,6 +1671,7 @@ var OyehelloChatElement = class extends HTMLElement {
1254
1671
  sendChip(text) {
1255
1672
  this.away = false;
1256
1673
  this.emojiOpen = false;
1674
+ this.serverSuggestions = [];
1257
1675
  void this.client?.sendMessage(text);
1258
1676
  this.updateClasses();
1259
1677
  }
@@ -1267,6 +1685,56 @@ var OyehelloChatElement = class extends HTMLElement {
1267
1685
  this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1268
1686
  void this.client?.start();
1269
1687
  });
1688
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
1689
+ }
1690
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
1691
+ startFresh() {
1692
+ this.serverSuggestions = null;
1693
+ this.receivedAt.clear();
1694
+ this.mdCache.clear();
1695
+ this.streamId = null;
1696
+ this.streamEl = null;
1697
+ this.renderedCount = 0;
1698
+ this.renderedBotFinal = 0;
1699
+ this.away = false;
1700
+ this.unseen = 0;
1701
+ this.client?.reset();
1702
+ this.started = true;
1703
+ void this.client?.start();
1704
+ setTimeout(() => this.textareaEl?.focus(), 60);
1705
+ }
1706
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
1707
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
1708
+ // or the browser hasn't allowed audio yet.
1709
+ maybeChime() {
1710
+ const last = this.messages[this.messages.length - 1];
1711
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
1712
+ }
1713
+ playChime() {
1714
+ if (this.getAttribute("sound") === "off") return;
1715
+ try {
1716
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
1717
+ if (!Ctx) return;
1718
+ if (!this.audioCtx) this.audioCtx = new Ctx();
1719
+ const ctx = this.audioCtx;
1720
+ if (ctx.state === "suspended") void ctx.resume();
1721
+ const now = ctx.currentTime;
1722
+ [880, 1174.66].forEach((freq, i) => {
1723
+ const osc = ctx.createOscillator();
1724
+ const gain = ctx.createGain();
1725
+ osc.type = "sine";
1726
+ osc.frequency.value = freq;
1727
+ const t = now + i * 0.11;
1728
+ gain.gain.setValueAtTime(1e-4, t);
1729
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
1730
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
1731
+ osc.connect(gain);
1732
+ gain.connect(ctx.destination);
1733
+ osc.start(t);
1734
+ osc.stop(t + 0.2);
1735
+ });
1736
+ } catch {
1737
+ }
1270
1738
  }
1271
1739
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1272
1740
  toggleEmoji() {