@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.cjs CHANGED
@@ -21,6 +21,9 @@ var ChatApiError = class extends Error {
21
21
  meta;
22
22
  };
23
23
 
24
+ // src/protocol.ts
25
+ var CHAT_PROTOCOL_VERSION = 1;
26
+
24
27
  // src/transport.ts
25
28
  async function asError(res, fallbackCode) {
26
29
  let body = {};
@@ -31,9 +34,10 @@ async function asError(res, fallbackCode) {
31
34
  return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
32
35
  }
33
36
  async function openSession(opts) {
34
- const body = { agentId: opts.agentId };
37
+ const body = { agentId: opts.agentId, protocol: CHAT_PROTOCOL_VERSION };
35
38
  if (opts.user) body.user = opts.user;
36
39
  if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
40
+ if (opts.fingerprint) body.fingerprint = opts.fingerprint;
37
41
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
38
42
  method: "POST",
39
43
  credentials: "omit",
@@ -63,6 +67,15 @@ async function postMessage(opts) {
63
67
  });
64
68
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
65
69
  }
70
+ async function postPageViews(opts) {
71
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
72
+ method: "POST",
73
+ credentials: "omit",
74
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
75
+ body: JSON.stringify({ views: opts.views })
76
+ });
77
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
78
+ }
66
79
  async function endSession(opts) {
67
80
  await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
68
81
  method: "POST",
@@ -80,6 +93,15 @@ async function postToolResult(opts) {
80
93
  }).catch(() => {
81
94
  });
82
95
  }
96
+ async function refreshSessionToken(opts) {
97
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/refresh-token`, {
98
+ method: "POST",
99
+ credentials: "omit",
100
+ headers: { Authorization: `Bearer ${opts.sessionToken}` }
101
+ });
102
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
103
+ return await res.json();
104
+ }
83
105
  async function mintStreamTicket(opts) {
84
106
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
85
107
  method: "POST",
@@ -92,15 +114,18 @@ async function mintStreamTicket(opts) {
92
114
  function streamUrl(baseUrl, sessionId, ticket) {
93
115
  return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
94
116
  }
95
- async function presignAttachment(opts) {
96
- const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachments`, {
117
+ async function uploadAttachment(opts) {
118
+ const form = new FormData();
119
+ form.append("file", opts.file, opts.file.name);
120
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/upload`, {
97
121
  method: "POST",
98
122
  credentials: "omit",
99
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
100
- body: JSON.stringify({ contentType: opts.contentType })
123
+ headers: { Authorization: `Bearer ${opts.sessionToken}` },
124
+ body: form
101
125
  });
102
126
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
103
- return await res.json();
127
+ const j = await res.json();
128
+ return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
104
129
  }
105
130
  async function getAttachmentUrl(opts) {
106
131
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
@@ -112,9 +137,178 @@ async function getAttachmentUrl(opts) {
112
137
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
113
138
  return (await res.json()).url;
114
139
  }
115
- async function uploadToPresigned(uploadUrl, file, fetchImpl) {
116
- const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
117
- if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
140
+
141
+ // src/fingerprint.ts
142
+ async function sha256Hex(input) {
143
+ const g = globalThis;
144
+ try {
145
+ if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
146
+ const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
147
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
148
+ }
149
+ } catch {
150
+ }
151
+ let h = 5381;
152
+ for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
153
+ return h.toString(16).padStart(8, "0");
154
+ }
155
+ async function computeDeviceFingerprint() {
156
+ try {
157
+ const nav = typeof navigator !== "undefined" ? navigator : null;
158
+ if (!nav) return null;
159
+ const scr = typeof screen !== "undefined" ? screen : null;
160
+ const tz = (() => {
161
+ try {
162
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
163
+ } catch {
164
+ return "";
165
+ }
166
+ })();
167
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
168
+ const signals = [
169
+ nav.userAgent,
170
+ Array.isArray(nav.languages) ? nav.languages.join(",") : "",
171
+ nav.language,
172
+ nav.platform,
173
+ nav.hardwareConcurrency,
174
+ nav.deviceMemory,
175
+ scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
176
+ dpr,
177
+ tz
178
+ ].map((v) => String(v ?? "")).join("|");
179
+ return await sha256Hex(signals);
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ // src/journey.ts
186
+ var MAX_BUFFER = 25;
187
+ var storageKey = (agentId) => `oyehello-chat:journey:${agentId}`;
188
+ var store = () => {
189
+ try {
190
+ return globalThis.sessionStorage ?? null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ };
195
+ function load(agentId) {
196
+ const s = store();
197
+ if (!s) return { views: [], flushed: 0 };
198
+ try {
199
+ const raw = s.getItem(storageKey(agentId));
200
+ if (!raw) return { views: [], flushed: 0 };
201
+ const parsed = JSON.parse(raw);
202
+ const views = Array.isArray(parsed?.views) ? parsed.views.filter((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
203
+ const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
204
+ return { views, flushed: Math.min(flushedRaw, views.length) };
205
+ } catch {
206
+ return { views: [], flushed: 0 };
207
+ }
208
+ }
209
+ function save(agentId, data) {
210
+ const s = store();
211
+ if (!s) return;
212
+ try {
213
+ s.setItem(storageKey(agentId), JSON.stringify(data));
214
+ } catch {
215
+ }
216
+ }
217
+ var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
218
+ function recordView(agentId, path) {
219
+ try {
220
+ const p = sanitizePath(path);
221
+ const data = load(agentId);
222
+ const last = data.views[data.views.length - 1];
223
+ if (last && last.path === p) return;
224
+ data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
225
+ if (data.views.length > MAX_BUFFER) {
226
+ const drop = data.views.length - MAX_BUFFER;
227
+ data.views = data.views.slice(drop);
228
+ data.flushed = Math.max(0, data.flushed - drop);
229
+ }
230
+ save(agentId, data);
231
+ } catch {
232
+ }
233
+ }
234
+ function unsentViews(agentId) {
235
+ try {
236
+ const data = load(agentId);
237
+ return data.views.slice(data.flushed);
238
+ } catch {
239
+ return [];
240
+ }
241
+ }
242
+ function markFlushed(agentId, count) {
243
+ if (count <= 0) return;
244
+ try {
245
+ const data = load(agentId);
246
+ data.flushed = Math.min(data.views.length, data.flushed + count);
247
+ save(agentId, data);
248
+ } catch {
249
+ }
250
+ }
251
+ function installSpaHooks(onNav) {
252
+ const originalPush = history.pushState;
253
+ const originalReplace = history.replaceState;
254
+ let pushInstalled = false;
255
+ let replaceInstalled = false;
256
+ try {
257
+ history.pushState = function(...args) {
258
+ const ret = originalPush.apply(this, args);
259
+ try {
260
+ onNav();
261
+ } catch {
262
+ }
263
+ return ret;
264
+ };
265
+ pushInstalled = true;
266
+ } catch {
267
+ }
268
+ try {
269
+ history.replaceState = function(...args) {
270
+ const ret = originalReplace.apply(this, args);
271
+ try {
272
+ onNav();
273
+ } catch {
274
+ }
275
+ return ret;
276
+ };
277
+ replaceInstalled = true;
278
+ } catch {
279
+ }
280
+ const onPopState = () => {
281
+ try {
282
+ onNav();
283
+ } catch {
284
+ }
285
+ };
286
+ let popstateInstalled = false;
287
+ try {
288
+ window.addEventListener("popstate", onPopState);
289
+ popstateInstalled = true;
290
+ } catch {
291
+ }
292
+ return () => {
293
+ if (pushInstalled) {
294
+ try {
295
+ history.pushState = originalPush;
296
+ } catch {
297
+ }
298
+ }
299
+ if (replaceInstalled) {
300
+ try {
301
+ history.replaceState = originalReplace;
302
+ } catch {
303
+ }
304
+ }
305
+ if (popstateInstalled) {
306
+ try {
307
+ window.removeEventListener("popstate", onPopState);
308
+ } catch {
309
+ }
310
+ }
311
+ };
118
312
  }
119
313
 
120
314
  // src/client.ts
@@ -137,13 +331,97 @@ function createChatClient(options) {
137
331
  let reconnectTimer = null;
138
332
  let reconnectAttempts = 0;
139
333
  let closed = false;
334
+ let warnedProtocol = false;
335
+ let deviceFingerprint;
336
+ let refreshTimer = null;
140
337
  const toolHandlers = /* @__PURE__ */ new Map();
338
+ let journeyFlushChain = Promise.resolve();
339
+ let journeyFlushQueued = false;
340
+ const flushJourney = () => {
341
+ if (!session) return;
342
+ if (journeyFlushQueued) return;
343
+ journeyFlushQueued = true;
344
+ journeyFlushChain = journeyFlushChain.then(async () => {
345
+ journeyFlushQueued = false;
346
+ if (!session) return;
347
+ const pending = unsentViews(options.agentId);
348
+ if (pending.length === 0) return;
349
+ const s = session;
350
+ try {
351
+ await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
352
+ markFlushed(options.agentId, pending.length);
353
+ } catch {
354
+ }
355
+ });
356
+ };
141
357
  const clearReconnect = () => {
142
358
  if (reconnectTimer) {
143
359
  clearTimeout(reconnectTimer);
144
360
  reconnectTimer = null;
145
361
  }
146
362
  };
363
+ const clearRefresh = () => {
364
+ if (refreshTimer) {
365
+ clearTimeout(refreshTimer);
366
+ refreshTimer = null;
367
+ }
368
+ };
369
+ const scheduleRefresh = () => {
370
+ clearRefresh();
371
+ if (closed || !session) return;
372
+ const untilExpiry = Date.parse(session.expiresAt) - Date.now();
373
+ const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
374
+ refreshTimer = setTimeout(async () => {
375
+ if (closed || !session) return;
376
+ try {
377
+ const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
378
+ session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
379
+ persistSession(session);
380
+ scheduleRefresh();
381
+ } catch (err) {
382
+ const code = err?.code;
383
+ if (code === "session_ended" || code === "session_expired") clearStoredSession();
384
+ }
385
+ }, delay);
386
+ refreshTimer.unref?.();
387
+ };
388
+ const sessKey = `oyehello-chat-sess:${options.agentId}`;
389
+ const store2 = (() => {
390
+ try {
391
+ return globalThis.sessionStorage ?? null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ })();
396
+ const loadStoredSession = () => {
397
+ if (!store2) return null;
398
+ try {
399
+ const raw = store2.getItem(sessKey);
400
+ if (!raw) return null;
401
+ const s = JSON.parse(raw);
402
+ if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
403
+ const exp = Date.parse(s.expiresAt);
404
+ if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
405
+ store2.removeItem(sessKey);
406
+ return null;
407
+ }
408
+ return s;
409
+ } catch {
410
+ return null;
411
+ }
412
+ };
413
+ const persistSession = (s) => {
414
+ try {
415
+ store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
416
+ } catch {
417
+ }
418
+ };
419
+ const clearStoredSession = () => {
420
+ try {
421
+ store2?.removeItem(sessKey);
422
+ } catch {
423
+ }
424
+ };
147
425
  const emit = (e) => {
148
426
  for (const l of listeners) l(e);
149
427
  };
@@ -231,6 +509,14 @@ function createChatClient(options) {
231
509
  emit({ type: "agentTyping", active: !!ev.active });
232
510
  } else if (ev.type === "suggestions") {
233
511
  emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
512
+ } else if (ev.type === "done") {
513
+ closed = true;
514
+ clearReconnect();
515
+ clearRefresh();
516
+ es?.close();
517
+ es = null;
518
+ clearStoredSession();
519
+ setState("ended");
234
520
  }
235
521
  };
236
522
  const openStream = async () => {
@@ -262,6 +548,7 @@ function createChatClient(options) {
262
548
  reconnectTimer = setTimeout(() => {
263
549
  openStream().catch((err) => {
264
550
  if (err instanceof ChatApiError && err.code === "session_expired") {
551
+ clearStoredSession();
265
552
  open().catch((e) => {
266
553
  emit({ type: "error", error: e });
267
554
  scheduleReconnect();
@@ -273,16 +560,32 @@ function createChatClient(options) {
273
560
  }, delay);
274
561
  };
275
562
  const open = async () => {
276
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
563
+ const resumed = loadStoredSession();
564
+ if (resumed) {
565
+ session = resumed;
566
+ emit({ type: "agent", agent: resumed.agent });
567
+ await openStream();
568
+ scheduleRefresh();
569
+ flushJourney();
570
+ return;
571
+ }
572
+ if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
573
+ 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 });
574
+ persistSession(session);
575
+ if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
576
+ warnedProtocol = true;
577
+ try {
578
+ console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
579
+ } catch {
580
+ }
581
+ }
277
582
  emit({ type: "agent", agent: session.agent });
278
583
  await openStream();
584
+ scheduleRefresh();
585
+ flushJourney();
279
586
  };
280
587
  const uploadFiles = async (files) => {
281
- return Promise.all(files.map(async (file) => {
282
- const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
283
- await uploadToPresigned(uploadUrl, file, fetchImpl);
284
- return { storageRef, contentType: file.type, name: file.name };
285
- }));
588
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
286
589
  };
287
590
  return {
288
591
  getMessages: () => messages.slice(),
@@ -301,6 +604,13 @@ function createChatClient(options) {
301
604
  getWidgetConfig() {
302
605
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
303
606
  },
607
+ recordPageView(path) {
608
+ try {
609
+ recordView(options.agentId, path);
610
+ flushJourney();
611
+ } catch {
612
+ }
613
+ },
304
614
  async start() {
305
615
  if (state === "opening" || state === "ready") return;
306
616
  closed = false;
@@ -346,6 +656,7 @@ function createChatClient(options) {
346
656
  } catch (err) {
347
657
  if (err instanceof ChatApiError && err.code === "session_expired") {
348
658
  try {
659
+ clearStoredSession();
349
660
  await open();
350
661
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
351
662
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
@@ -357,13 +668,38 @@ function createChatClient(options) {
357
668
  }
358
669
  }
359
670
  },
671
+ detach() {
672
+ closed = true;
673
+ clearReconnect();
674
+ clearRefresh();
675
+ es?.close();
676
+ es = null;
677
+ },
360
678
  async end() {
361
679
  closed = true;
362
680
  clearReconnect();
681
+ clearRefresh();
682
+ clearStoredSession();
363
683
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
364
684
  es?.close();
365
685
  es = null;
366
686
  setState("ended");
687
+ },
688
+ reset() {
689
+ clearStoredSession();
690
+ es?.close();
691
+ es = null;
692
+ clearReconnect();
693
+ clearRefresh();
694
+ session = null;
695
+ streamingId = null;
696
+ handoffState = "ai";
697
+ messages.length = 0;
698
+ seenTurnIds.clear();
699
+ seenCallIds.clear();
700
+ emit({ type: "messages", messages: [] });
701
+ emit({ type: "handoff", state: "ai" });
702
+ setState("idle");
367
703
  }
368
704
  };
369
705
  }
@@ -445,6 +781,10 @@ var STYLES = `
445
781
  --oc-glass-border: rgba(9,13,20,0.12);
446
782
 
447
783
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
784
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
785
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
786
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
787
+ display: block; visibility: visible; opacity: 1;
448
788
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
449
789
  -webkit-font-smoothing: antialiased;
450
790
  }
@@ -484,7 +824,7 @@ var STYLES = `
484
824
  .launcher:hover { transform: scale(1.06); }
485
825
  .launcher:active { transform: scale(0.93); }
486
826
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
487
- .oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
827
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
488
828
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
489
829
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
490
830
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -506,7 +846,7 @@ var STYLES = `
506
846
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
507
847
  }
508
848
  .oc.has-teaser .teaser { display: flex; }
509
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
849
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
510
850
  .teaser-btn {
511
851
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
512
852
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -553,6 +893,12 @@ var STYLES = `
553
893
  .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); }
554
894
  .oc-err-retry:hover { background: var(--oc-tint-3); }
555
895
 
896
+ /* Session-ended state, inside the chat window */
897
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
898
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
899
+ .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); }
900
+ .oc-newmsg:hover { filter: brightness(1.08); }
901
+
556
902
  /* \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 */
557
903
  .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; }
558
904
  .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
@@ -597,6 +943,7 @@ var STYLES = `
597
943
 
598
944
  /* \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 */
599
945
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
946
+ .composer[hidden] { display: none; }
600
947
  .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); }
601
948
  .oc.focus .field { border-color: var(--oc-ac); }
602
949
  .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; }
@@ -822,14 +1169,29 @@ var OyehelloChatElement = class extends HTMLElement {
822
1169
  // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
823
1170
  // widget prefers these over the static `suggestions` attribute.
824
1171
  serverSuggestions = null;
1172
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
1173
+ // that element in place instead of rebuilding the whole message list on every token.
1174
+ streamId = null;
1175
+ streamEl = null;
1176
+ audioCtx = null;
825
1177
  teaserText = "Need a hand? Ask me anything.";
826
1178
  teaserShownOnce = false;
1179
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
1180
+ // composer is disabled (opening a session is refused server-side anyway).
1181
+ offline = false;
1182
+ offlineMessage = "We're offline right now. Please check back during business hours.";
1183
+ configLoaded = false;
1184
+ // widget-config resolved (so we know online/offline before starting)
827
1185
  idleTimer = null;
828
1186
  idleReset = null;
1187
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1188
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
1189
+ uninstallSpaHooks = null;
829
1190
  // ── shadow refs ──
830
1191
  oc;
831
1192
  bodyEl;
832
1193
  textareaEl;
1194
+ composerEl;
833
1195
  fileEl;
834
1196
  titleEl;
835
1197
  statusEl;
@@ -851,7 +1213,16 @@ var OyehelloChatElement = class extends HTMLElement {
851
1213
  this.pendingTools = {};
852
1214
  }
853
1215
  this.off = this.client.on((e) => this.onClientEvent(e));
1216
+ recordView(agentId, location.pathname);
1217
+ try {
1218
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
1219
+ } catch {
1220
+ this.uninstallSpaHooks = null;
1221
+ }
854
1222
  this.renderShell();
1223
+ this.style.setProperty("display", "block", "important");
1224
+ this.style.setProperty("visibility", "visible", "important");
1225
+ this.style.setProperty("opacity", "1", "important");
855
1226
  this.renderMessages();
856
1227
  this.updateHeader();
857
1228
  this.updateStatus();
@@ -868,7 +1239,7 @@ var OyehelloChatElement = class extends HTMLElement {
868
1239
  }
869
1240
  this.updateClasses(true);
870
1241
  this.timers.push(setTimeout(() => {
871
- this.oc?.classList.remove("ring");
1242
+ this.oc?.classList.remove("show-ring");
872
1243
  }, 6e3));
873
1244
  this.armIdleTeaser();
874
1245
  }
@@ -921,7 +1292,19 @@ var OyehelloChatElement = class extends HTMLElement {
921
1292
  }
922
1293
  if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
923
1294
  }
1295
+ if (cfg.offline) {
1296
+ this.offline = true;
1297
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
1298
+ this.renderMessages();
1299
+ this.updateStatus();
1300
+ }
924
1301
  } catch {
1302
+ } finally {
1303
+ this.configLoaded = true;
1304
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
1305
+ this.started = true;
1306
+ void this.client.start();
1307
+ }
925
1308
  }
926
1309
  }
927
1310
  applyTeaserText() {
@@ -943,8 +1326,10 @@ var OyehelloChatElement = class extends HTMLElement {
943
1326
  }
944
1327
  disconnectedCallback() {
945
1328
  this.off?.();
946
- void this.client?.end();
1329
+ this.client?.detach();
947
1330
  this.teardownIdle();
1331
+ this.uninstallSpaHooks?.();
1332
+ this.uninstallSpaHooks = null;
948
1333
  for (const t of this.timers) clearTimeout(t);
949
1334
  for (const url of this.imagePreviewCache.values()) {
950
1335
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1022,6 +1407,7 @@ var OyehelloChatElement = class extends HTMLElement {
1022
1407
  this.oc = root.querySelector(".oc");
1023
1408
  this.bodyEl = root.querySelector(".body");
1024
1409
  this.textareaEl = root.querySelector("textarea.input");
1410
+ this.composerEl = root.querySelector(".composer");
1025
1411
  this.fileEl = root.querySelector('input[type="file"]');
1026
1412
  this.titleEl = root.querySelector(".hd-title");
1027
1413
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1080,18 +1466,22 @@ var OyehelloChatElement = class extends HTMLElement {
1080
1466
  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>`;
1081
1467
  }
1082
1468
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
1469
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
1470
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
1471
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
1472
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
1083
1473
  updateClasses(initialRing = false) {
1084
1474
  if (!this.oc) return;
1085
1475
  const c = this.oc.classList;
1086
1476
  c.toggle("open", this.uiOpen);
1087
1477
  c.toggle("max", this.uiMax);
1088
1478
  c.toggle("emoji", this.emojiOpen);
1089
- c.toggle("teaser", this.teaserActive);
1479
+ c.toggle("show-teaser", this.teaserActive);
1090
1480
  c.toggle("away", this.away);
1091
1481
  c.toggle("focus", this.focused);
1092
1482
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1093
1483
  c.toggle("unread", this.unseen > 0);
1094
- if (initialRing) c.add("ring");
1484
+ if (initialRing) c.add("show-ring");
1095
1485
  const badge = this.oc.querySelector(".badge");
1096
1486
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1097
1487
  const launcher = this.oc.querySelector(".launcher");
@@ -1110,7 +1500,7 @@ var OyehelloChatElement = class extends HTMLElement {
1110
1500
  sessionStorage.setItem(this.key("open"), "1");
1111
1501
  } catch {
1112
1502
  }
1113
- if (!this.started && this.client) {
1503
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
1114
1504
  this.started = true;
1115
1505
  void this.client.start();
1116
1506
  }
@@ -1158,16 +1548,23 @@ var OyehelloChatElement = class extends HTMLElement {
1158
1548
  }
1159
1549
  updateStatus() {
1160
1550
  if (!this.textareaEl) return;
1161
- const disabled = this.state !== "ready";
1551
+ const disabled = this.state !== "ready" || this.offline;
1162
1552
  this.textareaEl.disabled = disabled;
1553
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1163
1554
  const handoff = HANDOFF_STATUS[this.handoffState];
1164
- const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1555
+ const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1165
1556
  if (this.statusEl) this.statusEl.textContent = text;
1166
1557
  }
1167
1558
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1168
1559
  renderMessages() {
1169
1560
  if (!this.bodyEl) return;
1170
1561
  const body = this.bodyEl;
1562
+ const last = this.messages[this.messages.length - 1];
1563
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
1564
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
1565
+ if (!this.away) this.scrollToBottom();
1566
+ return;
1567
+ }
1171
1568
  const prevTop = body.scrollTop;
1172
1569
  const added = this.messages.length - this.renderedCount;
1173
1570
  const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
@@ -1187,13 +1584,29 @@ var OyehelloChatElement = class extends HTMLElement {
1187
1584
  if (this.state === "error" && this.lastError) {
1188
1585
  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>`;
1189
1586
  }
1587
+ if (this.state === "ended") {
1588
+ 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>`;
1589
+ }
1590
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
1591
+ html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
1592
+ }
1190
1593
  body.innerHTML = html;
1191
1594
  this.hydrateAttachmentImages();
1192
1595
  this.bindChips();
1193
1596
  this.renderedCount = this.messages.length;
1194
1597
  this.renderedBotFinal = botFinal;
1598
+ this.streamId = null;
1599
+ this.streamEl = null;
1600
+ if (last && last.role === "assistant" && last.streaming) {
1601
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
1602
+ this.streamEl = mds[mds.length - 1] ?? null;
1603
+ if (this.streamEl) this.streamId = last.id;
1604
+ }
1195
1605
  if (!this.uiOpen) {
1196
- if (added > 0) this.unseen += added;
1606
+ if (added > 0) {
1607
+ this.unseen += added;
1608
+ this.maybeChime();
1609
+ }
1197
1610
  this.updateClasses();
1198
1611
  return;
1199
1612
  }
@@ -1201,7 +1614,10 @@ var OyehelloChatElement = class extends HTMLElement {
1201
1614
  else if (!this.away) this.scrollToBottom();
1202
1615
  else {
1203
1616
  body.scrollTop = prevTop;
1204
- if (added > 0) this.unseen += added;
1617
+ if (added > 0) {
1618
+ this.unseen += added;
1619
+ this.maybeChime();
1620
+ }
1205
1621
  }
1206
1622
  this.updateClasses();
1207
1623
  }
@@ -1252,6 +1668,7 @@ var OyehelloChatElement = class extends HTMLElement {
1252
1668
  }
1253
1669
  this.away = false;
1254
1670
  this.emojiOpen = false;
1671
+ this.serverSuggestions = [];
1255
1672
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1256
1673
  this.textareaEl.value = "";
1257
1674
  this.textareaEl.style.height = "auto";
@@ -1260,6 +1677,7 @@ var OyehelloChatElement = class extends HTMLElement {
1260
1677
  sendChip(text) {
1261
1678
  this.away = false;
1262
1679
  this.emojiOpen = false;
1680
+ this.serverSuggestions = [];
1263
1681
  void this.client?.sendMessage(text);
1264
1682
  this.updateClasses();
1265
1683
  }
@@ -1273,6 +1691,56 @@ var OyehelloChatElement = class extends HTMLElement {
1273
1691
  this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1274
1692
  void this.client?.start();
1275
1693
  });
1694
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
1695
+ }
1696
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
1697
+ startFresh() {
1698
+ this.serverSuggestions = null;
1699
+ this.receivedAt.clear();
1700
+ this.mdCache.clear();
1701
+ this.streamId = null;
1702
+ this.streamEl = null;
1703
+ this.renderedCount = 0;
1704
+ this.renderedBotFinal = 0;
1705
+ this.away = false;
1706
+ this.unseen = 0;
1707
+ this.client?.reset();
1708
+ this.started = true;
1709
+ void this.client?.start();
1710
+ setTimeout(() => this.textareaEl?.focus(), 60);
1711
+ }
1712
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
1713
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
1714
+ // or the browser hasn't allowed audio yet.
1715
+ maybeChime() {
1716
+ const last = this.messages[this.messages.length - 1];
1717
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
1718
+ }
1719
+ playChime() {
1720
+ if (this.getAttribute("sound") === "off") return;
1721
+ try {
1722
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
1723
+ if (!Ctx) return;
1724
+ if (!this.audioCtx) this.audioCtx = new Ctx();
1725
+ const ctx = this.audioCtx;
1726
+ if (ctx.state === "suspended") void ctx.resume();
1727
+ const now = ctx.currentTime;
1728
+ [880, 1174.66].forEach((freq, i) => {
1729
+ const osc = ctx.createOscillator();
1730
+ const gain = ctx.createGain();
1731
+ osc.type = "sine";
1732
+ osc.frequency.value = freq;
1733
+ const t = now + i * 0.11;
1734
+ gain.gain.setValueAtTime(1e-4, t);
1735
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
1736
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
1737
+ osc.connect(gain);
1738
+ gain.connect(ctx.destination);
1739
+ osc.start(t);
1740
+ osc.stop(t + 0.2);
1741
+ });
1742
+ } catch {
1743
+ }
1276
1744
  }
1277
1745
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1278
1746
  toggleEmoji() {