@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.
@@ -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",
@@ -57,6 +61,15 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 j2 = await res.json();
122
+ return { storageRef: j2.storageRef, contentType: j2.contentType, name: j2.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 @@ var OyehelloChat = (function (exports) {
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((b2) => b2.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((v2) => String(v2 ?? "")).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((v2) => !!v2 && typeof v2.path === "string" && typeof v2.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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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: text2, attachments: freshAttachments, fetchImpl });
@@ -351,13 +662,38 @@ var OyehelloChat = (function (exports) {
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
  }
@@ -3319,6 +3655,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3319
3655
  --oc-glass-border: rgba(9,13,20,0.12);
3320
3656
 
3321
3657
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
3658
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
3659
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
3660
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
3661
+ display: block; visibility: visible; opacity: 1;
3322
3662
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
3323
3663
  -webkit-font-smoothing: antialiased;
3324
3664
  }
@@ -3358,7 +3698,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3358
3698
  .launcher:hover { transform: scale(1.06); }
3359
3699
  .launcher:active { transform: scale(0.93); }
3360
3700
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
3361
- .oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
3701
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
3362
3702
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
3363
3703
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
3364
3704
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -3380,7 +3720,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3380
3720
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
3381
3721
  }
3382
3722
  .oc.has-teaser .teaser { display: flex; }
3383
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
3723
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
3384
3724
  .teaser-btn {
3385
3725
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
3386
3726
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -3427,6 +3767,12 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3427
3767
  .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); }
3428
3768
  .oc-err-retry:hover { background: var(--oc-tint-3); }
3429
3769
 
3770
+ /* Session-ended state, inside the chat window */
3771
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
3772
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
3773
+ .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); }
3774
+ .oc-newmsg:hover { filter: brightness(1.08); }
3775
+
3430
3776
  /* \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 */
3431
3777
  .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; }
3432
3778
  .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
@@ -3471,6 +3817,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3471
3817
 
3472
3818
  /* \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 */
3473
3819
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
3820
+ .composer[hidden] { display: none; }
3474
3821
  .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); }
3475
3822
  .oc.focus .field { border-color: var(--oc-ac); }
3476
3823
  .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; }
@@ -3696,14 +4043,29 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3696
4043
  // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
3697
4044
  // widget prefers these over the static `suggestions` attribute.
3698
4045
  serverSuggestions = null;
4046
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
4047
+ // that element in place instead of rebuilding the whole message list on every token.
4048
+ streamId = null;
4049
+ streamEl = null;
4050
+ audioCtx = null;
3699
4051
  teaserText = "Need a hand? Ask me anything.";
3700
4052
  teaserShownOnce = false;
4053
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
4054
+ // composer is disabled (opening a session is refused server-side anyway).
4055
+ offline = false;
4056
+ offlineMessage = "We're offline right now. Please check back during business hours.";
4057
+ configLoaded = false;
4058
+ // widget-config resolved (so we know online/offline before starting)
3701
4059
  idleTimer = null;
3702
4060
  idleReset = null;
4061
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
4062
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
4063
+ uninstallSpaHooks = null;
3703
4064
  // ── shadow refs ──
3704
4065
  oc;
3705
4066
  bodyEl;
3706
4067
  textareaEl;
4068
+ composerEl;
3707
4069
  fileEl;
3708
4070
  titleEl;
3709
4071
  statusEl;
@@ -3725,7 +4087,16 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3725
4087
  this.pendingTools = {};
3726
4088
  }
3727
4089
  this.off = this.client.on((e) => this.onClientEvent(e));
4090
+ recordView(agentId, location.pathname);
4091
+ try {
4092
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
4093
+ } catch {
4094
+ this.uninstallSpaHooks = null;
4095
+ }
3728
4096
  this.renderShell();
4097
+ this.style.setProperty("display", "block", "important");
4098
+ this.style.setProperty("visibility", "visible", "important");
4099
+ this.style.setProperty("opacity", "1", "important");
3729
4100
  this.renderMessages();
3730
4101
  this.updateHeader();
3731
4102
  this.updateStatus();
@@ -3742,7 +4113,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3742
4113
  }
3743
4114
  this.updateClasses(true);
3744
4115
  this.timers.push(setTimeout(() => {
3745
- this.oc?.classList.remove("ring");
4116
+ this.oc?.classList.remove("show-ring");
3746
4117
  }, 6e3));
3747
4118
  this.armIdleTeaser();
3748
4119
  }
@@ -3795,7 +4166,19 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3795
4166
  }
3796
4167
  if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
3797
4168
  }
4169
+ if (cfg.offline) {
4170
+ this.offline = true;
4171
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
4172
+ this.renderMessages();
4173
+ this.updateStatus();
4174
+ }
3798
4175
  } catch {
4176
+ } finally {
4177
+ this.configLoaded = true;
4178
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
4179
+ this.started = true;
4180
+ void this.client.start();
4181
+ }
3799
4182
  }
3800
4183
  }
3801
4184
  applyTeaserText() {
@@ -3817,8 +4200,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3817
4200
  }
3818
4201
  disconnectedCallback() {
3819
4202
  this.off?.();
3820
- void this.client?.end();
4203
+ this.client?.detach();
3821
4204
  this.teardownIdle();
4205
+ this.uninstallSpaHooks?.();
4206
+ this.uninstallSpaHooks = null;
3822
4207
  for (const t of this.timers) clearTimeout(t);
3823
4208
  for (const url of this.imagePreviewCache.values()) {
3824
4209
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -3896,6 +4281,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3896
4281
  this.oc = root.querySelector(".oc");
3897
4282
  this.bodyEl = root.querySelector(".body");
3898
4283
  this.textareaEl = root.querySelector("textarea.input");
4284
+ this.composerEl = root.querySelector(".composer");
3899
4285
  this.fileEl = root.querySelector('input[type="file"]');
3900
4286
  this.titleEl = root.querySelector(".hd-title");
3901
4287
  this.statusEl = root.querySelector(".hd-status .t");
@@ -3954,18 +4340,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3954
4340
  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>`;
3955
4341
  }
3956
4342
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
4343
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
4344
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
4345
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
4346
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
3957
4347
  updateClasses(initialRing = false) {
3958
4348
  if (!this.oc) return;
3959
4349
  const c = this.oc.classList;
3960
4350
  c.toggle("open", this.uiOpen);
3961
4351
  c.toggle("max", this.uiMax);
3962
4352
  c.toggle("emoji", this.emojiOpen);
3963
- c.toggle("teaser", this.teaserActive);
4353
+ c.toggle("show-teaser", this.teaserActive);
3964
4354
  c.toggle("away", this.away);
3965
4355
  c.toggle("focus", this.focused);
3966
4356
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
3967
4357
  c.toggle("unread", this.unseen > 0);
3968
- if (initialRing) c.add("ring");
4358
+ if (initialRing) c.add("show-ring");
3969
4359
  const badge = this.oc.querySelector(".badge");
3970
4360
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
3971
4361
  const launcher = this.oc.querySelector(".launcher");
@@ -3984,7 +4374,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3984
4374
  sessionStorage.setItem(this.key("open"), "1");
3985
4375
  } catch {
3986
4376
  }
3987
- if (!this.started && this.client) {
4377
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
3988
4378
  this.started = true;
3989
4379
  void this.client.start();
3990
4380
  }
@@ -4032,16 +4422,23 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4032
4422
  }
4033
4423
  updateStatus() {
4034
4424
  if (!this.textareaEl) return;
4035
- const disabled = this.state !== "ready";
4425
+ const disabled = this.state !== "ready" || this.offline;
4036
4426
  this.textareaEl.disabled = disabled;
4427
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
4037
4428
  const handoff = HANDOFF_STATUS[this.handoffState];
4038
- const text2 = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4429
+ const text2 = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4039
4430
  if (this.statusEl) this.statusEl.textContent = text2;
4040
4431
  }
4041
4432
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
4042
4433
  renderMessages() {
4043
4434
  if (!this.bodyEl) return;
4044
4435
  const body = this.bodyEl;
4436
+ const last = this.messages[this.messages.length - 1];
4437
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
4438
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
4439
+ if (!this.away) this.scrollToBottom();
4440
+ return;
4441
+ }
4045
4442
  const prevTop = body.scrollTop;
4046
4443
  const added = this.messages.length - this.renderedCount;
4047
4444
  const botFinal = this.messages.filter((m2) => m2.role === "assistant" && !m2.streaming).length;
@@ -4061,13 +4458,29 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4061
4458
  if (this.state === "error" && this.lastError) {
4062
4459
  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>`;
4063
4460
  }
4461
+ if (this.state === "ended") {
4462
+ 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>`;
4463
+ }
4464
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
4465
+ html2 += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
4466
+ }
4064
4467
  body.innerHTML = html2;
4065
4468
  this.hydrateAttachmentImages();
4066
4469
  this.bindChips();
4067
4470
  this.renderedCount = this.messages.length;
4068
4471
  this.renderedBotFinal = botFinal;
4472
+ this.streamId = null;
4473
+ this.streamEl = null;
4474
+ if (last && last.role === "assistant" && last.streaming) {
4475
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
4476
+ this.streamEl = mds[mds.length - 1] ?? null;
4477
+ if (this.streamEl) this.streamId = last.id;
4478
+ }
4069
4479
  if (!this.uiOpen) {
4070
- if (added > 0) this.unseen += added;
4480
+ if (added > 0) {
4481
+ this.unseen += added;
4482
+ this.maybeChime();
4483
+ }
4071
4484
  this.updateClasses();
4072
4485
  return;
4073
4486
  }
@@ -4075,7 +4488,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4075
4488
  else if (!this.away) this.scrollToBottom();
4076
4489
  else {
4077
4490
  body.scrollTop = prevTop;
4078
- if (added > 0) this.unseen += added;
4491
+ if (added > 0) {
4492
+ this.unseen += added;
4493
+ this.maybeChime();
4494
+ }
4079
4495
  }
4080
4496
  this.updateClasses();
4081
4497
  }
@@ -4126,6 +4542,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4126
4542
  }
4127
4543
  this.away = false;
4128
4544
  this.emojiOpen = false;
4545
+ this.serverSuggestions = [];
4129
4546
  void this.client?.sendMessage(text2, files.length > 0 ? files : void 0);
4130
4547
  this.textareaEl.value = "";
4131
4548
  this.textareaEl.style.height = "auto";
@@ -4134,6 +4551,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4134
4551
  sendChip(text2) {
4135
4552
  this.away = false;
4136
4553
  this.emojiOpen = false;
4554
+ this.serverSuggestions = [];
4137
4555
  void this.client?.sendMessage(text2);
4138
4556
  this.updateClasses();
4139
4557
  }
@@ -4147,6 +4565,56 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4147
4565
  this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
4148
4566
  void this.client?.start();
4149
4567
  });
4568
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
4569
+ }
4570
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
4571
+ startFresh() {
4572
+ this.serverSuggestions = null;
4573
+ this.receivedAt.clear();
4574
+ this.mdCache.clear();
4575
+ this.streamId = null;
4576
+ this.streamEl = null;
4577
+ this.renderedCount = 0;
4578
+ this.renderedBotFinal = 0;
4579
+ this.away = false;
4580
+ this.unseen = 0;
4581
+ this.client?.reset();
4582
+ this.started = true;
4583
+ void this.client?.start();
4584
+ setTimeout(() => this.textareaEl?.focus(), 60);
4585
+ }
4586
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
4587
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
4588
+ // or the browser hasn't allowed audio yet.
4589
+ maybeChime() {
4590
+ const last = this.messages[this.messages.length - 1];
4591
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
4592
+ }
4593
+ playChime() {
4594
+ if (this.getAttribute("sound") === "off") return;
4595
+ try {
4596
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
4597
+ if (!Ctx) return;
4598
+ if (!this.audioCtx) this.audioCtx = new Ctx();
4599
+ const ctx = this.audioCtx;
4600
+ if (ctx.state === "suspended") void ctx.resume();
4601
+ const now = ctx.currentTime;
4602
+ [880, 1174.66].forEach((freq, i) => {
4603
+ const osc = ctx.createOscillator();
4604
+ const gain = ctx.createGain();
4605
+ osc.type = "sine";
4606
+ osc.frequency.value = freq;
4607
+ const t = now + i * 0.11;
4608
+ gain.gain.setValueAtTime(1e-4, t);
4609
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
4610
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
4611
+ osc.connect(gain);
4612
+ gain.connect(ctx.destination);
4613
+ osc.start(t);
4614
+ osc.stop(t + 0.2);
4615
+ });
4616
+ } catch {
4617
+ }
4150
4618
  }
4151
4619
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
4152
4620
  toggleEmoji() {