@oyehello/chat 0.5.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.d.cts CHANGED
@@ -140,6 +140,7 @@ interface ChatClient {
140
140
  start(): Promise<void>;
141
141
  sendMessage(text: string, files?: File[]): Promise<void>;
142
142
  end(): Promise<void>;
143
+ detach(): void;
143
144
  reset(): void;
144
145
  getMessages(): ChatMessage[];
145
146
  getState(): ChatClientState;
@@ -147,6 +148,7 @@ interface ChatClient {
147
148
  registerTools(handlers: Record<string, ChatToolHandler>): void;
148
149
  getAttachmentUrl(storageRef: string): Promise<string>;
149
150
  getWidgetConfig(): Promise<WidgetConfig>;
151
+ recordPageView(path: string): void;
150
152
  }
151
153
  declare function createChatClient(options: ChatClientOptions): ChatClient;
152
154
 
@@ -189,9 +191,11 @@ declare class OyehelloChatElement extends HTMLElement {
189
191
  private configLoaded;
190
192
  private idleTimer;
191
193
  private idleReset;
194
+ private uninstallSpaHooks;
192
195
  private oc;
193
196
  private bodyEl;
194
197
  private textareaEl;
198
+ private composerEl;
195
199
  private fileEl;
196
200
  private titleEl;
197
201
  private statusEl;
package/dist/index.d.ts CHANGED
@@ -140,6 +140,7 @@ interface ChatClient {
140
140
  start(): Promise<void>;
141
141
  sendMessage(text: string, files?: File[]): Promise<void>;
142
142
  end(): Promise<void>;
143
+ detach(): void;
143
144
  reset(): void;
144
145
  getMessages(): ChatMessage[];
145
146
  getState(): ChatClientState;
@@ -147,6 +148,7 @@ interface ChatClient {
147
148
  registerTools(handlers: Record<string, ChatToolHandler>): void;
148
149
  getAttachmentUrl(storageRef: string): Promise<string>;
149
150
  getWidgetConfig(): Promise<WidgetConfig>;
151
+ recordPageView(path: string): void;
150
152
  }
151
153
  declare function createChatClient(options: ChatClientOptions): ChatClient;
152
154
 
@@ -189,9 +191,11 @@ declare class OyehelloChatElement extends HTMLElement {
189
191
  private configLoaded;
190
192
  private idleTimer;
191
193
  private idleReset;
194
+ private uninstallSpaHooks;
192
195
  private oc;
193
196
  private bodyEl;
194
197
  private textareaEl;
198
+ private composerEl;
195
199
  private fileEl;
196
200
  private titleEl;
197
201
  private statusEl;
@@ -61,6 +61,15 @@ var OyehelloChat = (function (exports) {
61
61
  });
62
62
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
63
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
+ }
64
73
  async function endSession(opts) {
65
74
  await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
66
75
  method: "POST",
@@ -78,6 +87,15 @@ var OyehelloChat = (function (exports) {
78
87
  }).catch(() => {
79
88
  });
80
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
+ }
81
99
  async function mintStreamTicket(opts) {
82
100
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
83
101
  method: "POST",
@@ -90,15 +108,18 @@ var OyehelloChat = (function (exports) {
90
108
  function streamUrl(baseUrl, sessionId, ticket) {
91
109
  return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
92
110
  }
93
- async function presignAttachment(opts) {
94
- 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`, {
95
115
  method: "POST",
96
116
  credentials: "omit",
97
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
98
- body: JSON.stringify({ contentType: opts.contentType })
117
+ headers: { Authorization: `Bearer ${opts.sessionToken}` },
118
+ body: form
99
119
  });
100
120
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
101
- return await res.json();
121
+ const j2 = await res.json();
122
+ return { storageRef: j2.storageRef, contentType: j2.contentType, name: j2.name };
102
123
  }
103
124
  async function getAttachmentUrl(opts) {
104
125
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
@@ -110,10 +131,6 @@ var OyehelloChat = (function (exports) {
110
131
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
111
132
  return (await res.json()).url;
112
133
  }
113
- async function uploadToPresigned(uploadUrl, file, fetchImpl) {
114
- const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
115
- if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
116
- }
117
134
 
118
135
  // src/fingerprint.ts
119
136
  async function sha256Hex(input) {
@@ -159,6 +176,135 @@ var OyehelloChat = (function (exports) {
159
176
  }
160
177
  }
161
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
+ };
306
+ }
307
+
162
308
  // src/client.ts
163
309
  var seq = 0;
164
310
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -181,13 +327,95 @@ var OyehelloChat = (function (exports) {
181
327
  let closed = false;
182
328
  let warnedProtocol = false;
183
329
  let deviceFingerprint;
330
+ let refreshTimer = null;
184
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
+ };
185
351
  const clearReconnect = () => {
186
352
  if (reconnectTimer) {
187
353
  clearTimeout(reconnectTimer);
188
354
  reconnectTimer = null;
189
355
  }
190
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
+ };
191
419
  const emit = (e) => {
192
420
  for (const l of listeners) l(e);
193
421
  };
@@ -278,8 +506,10 @@ var OyehelloChat = (function (exports) {
278
506
  } else if (ev.type === "done") {
279
507
  closed = true;
280
508
  clearReconnect();
509
+ clearRefresh();
281
510
  es?.close();
282
511
  es = null;
512
+ clearStoredSession();
283
513
  setState("ended");
284
514
  }
285
515
  };
@@ -312,6 +542,7 @@ var OyehelloChat = (function (exports) {
312
542
  reconnectTimer = setTimeout(() => {
313
543
  openStream().catch((err) => {
314
544
  if (err instanceof ChatApiError && err.code === "session_expired") {
545
+ clearStoredSession();
315
546
  open().catch((e) => {
316
547
  emit({ type: "error", error: e });
317
548
  scheduleReconnect();
@@ -323,8 +554,18 @@ var OyehelloChat = (function (exports) {
323
554
  }, delay);
324
555
  };
325
556
  const open = async () => {
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
+ }
326
566
  if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
327
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);
328
569
  if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
329
570
  warnedProtocol = true;
330
571
  try {
@@ -334,13 +575,11 @@ var OyehelloChat = (function (exports) {
334
575
  }
335
576
  emit({ type: "agent", agent: session.agent });
336
577
  await openStream();
578
+ scheduleRefresh();
579
+ flushJourney();
337
580
  };
338
581
  const uploadFiles = async (files) => {
339
- return Promise.all(files.map(async (file) => {
340
- const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
341
- await uploadToPresigned(uploadUrl, file, fetchImpl);
342
- return { storageRef, contentType: file.type, name: file.name };
343
- }));
582
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
344
583
  };
345
584
  return {
346
585
  getMessages: () => messages.slice(),
@@ -359,6 +598,13 @@ var OyehelloChat = (function (exports) {
359
598
  getWidgetConfig() {
360
599
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
361
600
  },
601
+ recordPageView(path) {
602
+ try {
603
+ recordView(options.agentId, path);
604
+ flushJourney();
605
+ } catch {
606
+ }
607
+ },
362
608
  async start() {
363
609
  if (state === "opening" || state === "ready") return;
364
610
  closed = false;
@@ -404,6 +650,7 @@ var OyehelloChat = (function (exports) {
404
650
  } catch (err) {
405
651
  if (err instanceof ChatApiError && err.code === "session_expired") {
406
652
  try {
653
+ clearStoredSession();
407
654
  await open();
408
655
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
409
656
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text: text2, attachments: freshAttachments, fetchImpl });
@@ -415,18 +662,29 @@ var OyehelloChat = (function (exports) {
415
662
  }
416
663
  }
417
664
  },
665
+ detach() {
666
+ closed = true;
667
+ clearReconnect();
668
+ clearRefresh();
669
+ es?.close();
670
+ es = null;
671
+ },
418
672
  async end() {
419
673
  closed = true;
420
674
  clearReconnect();
675
+ clearRefresh();
676
+ clearStoredSession();
421
677
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
422
678
  es?.close();
423
679
  es = null;
424
680
  setState("ended");
425
681
  },
426
682
  reset() {
683
+ clearStoredSession();
427
684
  es?.close();
428
685
  es = null;
429
686
  clearReconnect();
687
+ clearRefresh();
430
688
  session = null;
431
689
  streamingId = null;
432
690
  handoffState = "ai";
@@ -3397,6 +3655,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3397
3655
  --oc-glass-border: rgba(9,13,20,0.12);
3398
3656
 
3399
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;
3400
3662
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
3401
3663
  -webkit-font-smoothing: antialiased;
3402
3664
  }
@@ -3436,7 +3698,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3436
3698
  .launcher:hover { transform: scale(1.06); }
3437
3699
  .launcher:active { transform: scale(0.93); }
3438
3700
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
3439
- .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; }
3440
3702
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
3441
3703
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
3442
3704
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -3458,7 +3720,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3458
3720
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
3459
3721
  }
3460
3722
  .oc.has-teaser .teaser { display: flex; }
3461
- .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; }
3462
3724
  .teaser-btn {
3463
3725
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
3464
3726
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -3555,6 +3817,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3555
3817
 
3556
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 */
3557
3819
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
3820
+ .composer[hidden] { display: none; }
3558
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); }
3559
3822
  .oc.focus .field { border-color: var(--oc-ac); }
3560
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; }
@@ -3795,10 +4058,14 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3795
4058
  // widget-config resolved (so we know online/offline before starting)
3796
4059
  idleTimer = null;
3797
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;
3798
4064
  // ── shadow refs ──
3799
4065
  oc;
3800
4066
  bodyEl;
3801
4067
  textareaEl;
4068
+ composerEl;
3802
4069
  fileEl;
3803
4070
  titleEl;
3804
4071
  statusEl;
@@ -3820,7 +4087,16 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3820
4087
  this.pendingTools = {};
3821
4088
  }
3822
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
+ }
3823
4096
  this.renderShell();
4097
+ this.style.setProperty("display", "block", "important");
4098
+ this.style.setProperty("visibility", "visible", "important");
4099
+ this.style.setProperty("opacity", "1", "important");
3824
4100
  this.renderMessages();
3825
4101
  this.updateHeader();
3826
4102
  this.updateStatus();
@@ -3837,7 +4113,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3837
4113
  }
3838
4114
  this.updateClasses(true);
3839
4115
  this.timers.push(setTimeout(() => {
3840
- this.oc?.classList.remove("ring");
4116
+ this.oc?.classList.remove("show-ring");
3841
4117
  }, 6e3));
3842
4118
  this.armIdleTeaser();
3843
4119
  }
@@ -3924,8 +4200,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3924
4200
  }
3925
4201
  disconnectedCallback() {
3926
4202
  this.off?.();
3927
- void this.client?.end();
4203
+ this.client?.detach();
3928
4204
  this.teardownIdle();
4205
+ this.uninstallSpaHooks?.();
4206
+ this.uninstallSpaHooks = null;
3929
4207
  for (const t of this.timers) clearTimeout(t);
3930
4208
  for (const url of this.imagePreviewCache.values()) {
3931
4209
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -4003,6 +4281,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4003
4281
  this.oc = root.querySelector(".oc");
4004
4282
  this.bodyEl = root.querySelector(".body");
4005
4283
  this.textareaEl = root.querySelector("textarea.input");
4284
+ this.composerEl = root.querySelector(".composer");
4006
4285
  this.fileEl = root.querySelector('input[type="file"]');
4007
4286
  this.titleEl = root.querySelector(".hd-title");
4008
4287
  this.statusEl = root.querySelector(".hd-status .t");
@@ -4061,18 +4340,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4061
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>`;
4062
4341
  }
4063
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.
4064
4347
  updateClasses(initialRing = false) {
4065
4348
  if (!this.oc) return;
4066
4349
  const c = this.oc.classList;
4067
4350
  c.toggle("open", this.uiOpen);
4068
4351
  c.toggle("max", this.uiMax);
4069
4352
  c.toggle("emoji", this.emojiOpen);
4070
- c.toggle("teaser", this.teaserActive);
4353
+ c.toggle("show-teaser", this.teaserActive);
4071
4354
  c.toggle("away", this.away);
4072
4355
  c.toggle("focus", this.focused);
4073
4356
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
4074
4357
  c.toggle("unread", this.unseen > 0);
4075
- if (initialRing) c.add("ring");
4358
+ if (initialRing) c.add("show-ring");
4076
4359
  const badge = this.oc.querySelector(".badge");
4077
4360
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
4078
4361
  const launcher = this.oc.querySelector(".launcher");
@@ -4141,6 +4424,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4141
4424
  if (!this.textareaEl) return;
4142
4425
  const disabled = this.state !== "ready" || this.offline;
4143
4426
  this.textareaEl.disabled = disabled;
4427
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
4144
4428
  const handoff = HANDOFF_STATUS[this.handoffState];
4145
4429
  const text2 = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4146
4430
  if (this.statusEl) this.statusEl.textContent = text2;