@oyehello/chat 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -61,6 +61,15 @@ async function postMessage(opts) {
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 @@ async function postToolResult(opts) {
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 @@ async function mintStreamTicket(opts) {
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 j = await res.json();
122
+ return { storageRef: j.storageRef, contentType: j.contentType, name: j.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 @@ async function getAttachmentUrl(opts) {
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,9 +176,161 @@ async function computeDeviceFingerprint() {
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((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
197
+ const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
198
+ return { views, flushed: Math.min(flushedRaw, views.length) };
199
+ } catch {
200
+ return { views: [], flushed: 0 };
201
+ }
202
+ }
203
+ function save(agentId, data) {
204
+ const s = store();
205
+ if (!s) return;
206
+ try {
207
+ s.setItem(storageKey(agentId), JSON.stringify(data));
208
+ } catch {
209
+ }
210
+ }
211
+ var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
212
+ function recordView(agentId, path) {
213
+ try {
214
+ const p = sanitizePath(path);
215
+ const data = load(agentId);
216
+ const last = data.views[data.views.length - 1];
217
+ if (last && last.path === p) return;
218
+ data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
219
+ if (data.views.length > MAX_BUFFER) {
220
+ const drop = data.views.length - MAX_BUFFER;
221
+ data.views = data.views.slice(drop);
222
+ data.flushed = Math.max(0, data.flushed - drop);
223
+ }
224
+ save(agentId, data);
225
+ } catch {
226
+ }
227
+ }
228
+ function unsentViews(agentId) {
229
+ try {
230
+ const data = load(agentId);
231
+ return data.views.slice(data.flushed);
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+ function markFlushed(agentId, count) {
237
+ if (count <= 0) return;
238
+ try {
239
+ const data = load(agentId);
240
+ data.flushed = Math.min(data.views.length, data.flushed + count);
241
+ save(agentId, data);
242
+ } catch {
243
+ }
244
+ }
245
+ function installSpaHooks(onNav) {
246
+ const originalPush = history.pushState;
247
+ const originalReplace = history.replaceState;
248
+ let pushInstalled = false;
249
+ let replaceInstalled = false;
250
+ try {
251
+ history.pushState = function(...args) {
252
+ const ret = originalPush.apply(this, args);
253
+ try {
254
+ onNav();
255
+ } catch {
256
+ }
257
+ return ret;
258
+ };
259
+ pushInstalled = true;
260
+ } catch {
261
+ }
262
+ try {
263
+ history.replaceState = function(...args) {
264
+ const ret = originalReplace.apply(this, args);
265
+ try {
266
+ onNav();
267
+ } catch {
268
+ }
269
+ return ret;
270
+ };
271
+ replaceInstalled = true;
272
+ } catch {
273
+ }
274
+ const onPopState = () => {
275
+ try {
276
+ onNav();
277
+ } catch {
278
+ }
279
+ };
280
+ let popstateInstalled = false;
281
+ try {
282
+ window.addEventListener("popstate", onPopState);
283
+ popstateInstalled = true;
284
+ } catch {
285
+ }
286
+ return () => {
287
+ if (pushInstalled) {
288
+ try {
289
+ history.pushState = originalPush;
290
+ } catch {
291
+ }
292
+ }
293
+ if (replaceInstalled) {
294
+ try {
295
+ history.replaceState = originalReplace;
296
+ } catch {
297
+ }
298
+ }
299
+ if (popstateInstalled) {
300
+ try {
301
+ window.removeEventListener("popstate", onPopState);
302
+ } catch {
303
+ }
304
+ }
305
+ };
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)}`;
311
+ var isSafeRelativePath = (p) => p.startsWith("/") && !p.startsWith("//");
312
+ var isSafeHttpUrl = (u) => {
313
+ try {
314
+ const parsed = new URL(u, window.location.origin);
315
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
316
+ } catch {
317
+ return false;
318
+ }
319
+ };
320
+ var BUILTIN_TOOL_HANDLERS = {
321
+ navigate: (args) => {
322
+ const path = String(args.path ?? "");
323
+ if (!isSafeRelativePath(path)) return { ok: false, error: "unsafe navigate path" };
324
+ window.location.assign(path);
325
+ return { ok: true };
326
+ },
327
+ openUrl: (args) => {
328
+ const url = String(args.url ?? "");
329
+ if (!isSafeHttpUrl(url)) return { ok: false, error: "unsafe url" };
330
+ window.open(url, args.newTab === false ? "_self" : "_blank", "noopener,noreferrer");
331
+ return { ok: true };
332
+ }
333
+ };
165
334
  function createChatClient(options) {
166
335
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
167
336
  const ES = options.eventSource ?? globalThis.EventSource;
@@ -181,13 +350,95 @@ function createChatClient(options) {
181
350
  let closed = false;
182
351
  let warnedProtocol = false;
183
352
  let deviceFingerprint;
353
+ let refreshTimer = null;
184
354
  const toolHandlers = /* @__PURE__ */ new Map();
355
+ let journeyFlushChain = Promise.resolve();
356
+ let journeyFlushQueued = false;
357
+ const flushJourney = () => {
358
+ if (!session) return;
359
+ if (journeyFlushQueued) return;
360
+ journeyFlushQueued = true;
361
+ journeyFlushChain = journeyFlushChain.then(async () => {
362
+ journeyFlushQueued = false;
363
+ if (!session) return;
364
+ const pending = unsentViews(options.agentId);
365
+ if (pending.length === 0) return;
366
+ const s = session;
367
+ try {
368
+ await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
369
+ markFlushed(options.agentId, pending.length);
370
+ } catch {
371
+ }
372
+ });
373
+ };
185
374
  const clearReconnect = () => {
186
375
  if (reconnectTimer) {
187
376
  clearTimeout(reconnectTimer);
188
377
  reconnectTimer = null;
189
378
  }
190
379
  };
380
+ const clearRefresh = () => {
381
+ if (refreshTimer) {
382
+ clearTimeout(refreshTimer);
383
+ refreshTimer = null;
384
+ }
385
+ };
386
+ const scheduleRefresh = () => {
387
+ clearRefresh();
388
+ if (closed || !session) return;
389
+ const untilExpiry = Date.parse(session.expiresAt) - Date.now();
390
+ const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
391
+ refreshTimer = setTimeout(async () => {
392
+ if (closed || !session) return;
393
+ try {
394
+ const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
395
+ session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
396
+ persistSession(session);
397
+ scheduleRefresh();
398
+ } catch (err) {
399
+ const code = err?.code;
400
+ if (code === "session_ended" || code === "session_expired") clearStoredSession();
401
+ }
402
+ }, delay);
403
+ refreshTimer.unref?.();
404
+ };
405
+ const sessKey = `oyehello-chat-sess:${options.agentId}`;
406
+ const store2 = (() => {
407
+ try {
408
+ return globalThis.sessionStorage ?? null;
409
+ } catch {
410
+ return null;
411
+ }
412
+ })();
413
+ const loadStoredSession = () => {
414
+ if (!store2) return null;
415
+ try {
416
+ const raw = store2.getItem(sessKey);
417
+ if (!raw) return null;
418
+ const s = JSON.parse(raw);
419
+ if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
420
+ const exp = Date.parse(s.expiresAt);
421
+ if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
422
+ store2.removeItem(sessKey);
423
+ return null;
424
+ }
425
+ return s;
426
+ } catch {
427
+ return null;
428
+ }
429
+ };
430
+ const persistSession = (s) => {
431
+ try {
432
+ store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
433
+ } catch {
434
+ }
435
+ };
436
+ const clearStoredSession = () => {
437
+ try {
438
+ store2?.removeItem(sessKey);
439
+ } catch {
440
+ }
441
+ };
191
442
  const emit = (e) => {
192
443
  for (const l of listeners) l(e);
193
444
  };
@@ -238,7 +489,7 @@ function createChatClient(options) {
238
489
  } else if (ev.type === "tool_call") {
239
490
  if (seenCallIds.has(ev.callId)) return;
240
491
  seenCallIds.add(ev.callId);
241
- const handler = toolHandlers.get(ev.name);
492
+ const handler = toolHandlers.get(ev.name) ?? BUILTIN_TOOL_HANDLERS[ev.name];
242
493
  void (async () => {
243
494
  if (!session) return;
244
495
  const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
@@ -278,8 +529,10 @@ function createChatClient(options) {
278
529
  } else if (ev.type === "done") {
279
530
  closed = true;
280
531
  clearReconnect();
532
+ clearRefresh();
281
533
  es?.close();
282
534
  es = null;
535
+ clearStoredSession();
283
536
  setState("ended");
284
537
  }
285
538
  };
@@ -312,6 +565,7 @@ function createChatClient(options) {
312
565
  reconnectTimer = setTimeout(() => {
313
566
  openStream().catch((err) => {
314
567
  if (err instanceof ChatApiError && err.code === "session_expired") {
568
+ clearStoredSession();
315
569
  open().catch((e) => {
316
570
  emit({ type: "error", error: e });
317
571
  scheduleReconnect();
@@ -323,8 +577,19 @@ function createChatClient(options) {
323
577
  }, delay);
324
578
  };
325
579
  const open = async () => {
580
+ const resumed = loadStoredSession();
581
+ if (resumed) {
582
+ session = resumed;
583
+ emit({ type: "agent", agent: resumed.agent });
584
+ await openStream();
585
+ scheduleRefresh();
586
+ flushJourney();
587
+ return;
588
+ }
326
589
  if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
327
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
590
+ const advertisedTools = /* @__PURE__ */ new Set([...toolHandlers.keys(), ...Object.keys(BUILTIN_TOOL_HANDLERS)]);
591
+ session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(advertisedTools), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
592
+ persistSession(session);
328
593
  if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
329
594
  warnedProtocol = true;
330
595
  try {
@@ -334,13 +599,11 @@ function createChatClient(options) {
334
599
  }
335
600
  emit({ type: "agent", agent: session.agent });
336
601
  await openStream();
602
+ scheduleRefresh();
603
+ flushJourney();
337
604
  };
338
605
  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
- }));
606
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
344
607
  };
345
608
  return {
346
609
  getMessages: () => messages.slice(),
@@ -359,6 +622,13 @@ function createChatClient(options) {
359
622
  getWidgetConfig() {
360
623
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
361
624
  },
625
+ recordPageView(path) {
626
+ try {
627
+ recordView(options.agentId, path);
628
+ flushJourney();
629
+ } catch {
630
+ }
631
+ },
362
632
  async start() {
363
633
  if (state === "opening" || state === "ready") return;
364
634
  closed = false;
@@ -404,6 +674,7 @@ function createChatClient(options) {
404
674
  } catch (err) {
405
675
  if (err instanceof ChatApiError && err.code === "session_expired") {
406
676
  try {
677
+ clearStoredSession();
407
678
  await open();
408
679
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
409
680
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
@@ -415,18 +686,29 @@ function createChatClient(options) {
415
686
  }
416
687
  }
417
688
  },
689
+ detach() {
690
+ closed = true;
691
+ clearReconnect();
692
+ clearRefresh();
693
+ es?.close();
694
+ es = null;
695
+ },
418
696
  async end() {
419
697
  closed = true;
420
698
  clearReconnect();
699
+ clearRefresh();
700
+ clearStoredSession();
421
701
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
422
702
  es?.close();
423
703
  es = null;
424
704
  setState("ended");
425
705
  },
426
706
  reset() {
707
+ clearStoredSession();
427
708
  es?.close();
428
709
  es = null;
429
710
  clearReconnect();
711
+ clearRefresh();
430
712
  session = null;
431
713
  streamingId = null;
432
714
  handoffState = "ai";
@@ -517,6 +799,10 @@ var STYLES = `
517
799
  --oc-glass-border: rgba(9,13,20,0.12);
518
800
 
519
801
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
802
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
803
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
804
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
805
+ display: block; visibility: visible; opacity: 1;
520
806
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
521
807
  -webkit-font-smoothing: antialiased;
522
808
  }
@@ -556,7 +842,7 @@ var STYLES = `
556
842
  .launcher:hover { transform: scale(1.06); }
557
843
  .launcher:active { transform: scale(0.93); }
558
844
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
559
- .oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
845
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
560
846
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
561
847
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
562
848
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -578,7 +864,7 @@ var STYLES = `
578
864
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
579
865
  }
580
866
  .oc.has-teaser .teaser { display: flex; }
581
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
867
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
582
868
  .teaser-btn {
583
869
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
584
870
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -675,6 +961,7 @@ var STYLES = `
675
961
 
676
962
  /* \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 */
677
963
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
964
+ .composer[hidden] { display: none; }
678
965
  .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); }
679
966
  .oc.focus .field { border-color: var(--oc-ac); }
680
967
  .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; }
@@ -915,10 +1202,14 @@ var OyehelloChatElement = class extends HTMLElement {
915
1202
  // widget-config resolved (so we know online/offline before starting)
916
1203
  idleTimer = null;
917
1204
  idleReset = null;
1205
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1206
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
1207
+ uninstallSpaHooks = null;
918
1208
  // ── shadow refs ──
919
1209
  oc;
920
1210
  bodyEl;
921
1211
  textareaEl;
1212
+ composerEl;
922
1213
  fileEl;
923
1214
  titleEl;
924
1215
  statusEl;
@@ -940,7 +1231,16 @@ var OyehelloChatElement = class extends HTMLElement {
940
1231
  this.pendingTools = {};
941
1232
  }
942
1233
  this.off = this.client.on((e) => this.onClientEvent(e));
1234
+ recordView(agentId, location.pathname);
1235
+ try {
1236
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
1237
+ } catch {
1238
+ this.uninstallSpaHooks = null;
1239
+ }
943
1240
  this.renderShell();
1241
+ this.style.setProperty("display", "block", "important");
1242
+ this.style.setProperty("visibility", "visible", "important");
1243
+ this.style.setProperty("opacity", "1", "important");
944
1244
  this.renderMessages();
945
1245
  this.updateHeader();
946
1246
  this.updateStatus();
@@ -957,7 +1257,7 @@ var OyehelloChatElement = class extends HTMLElement {
957
1257
  }
958
1258
  this.updateClasses(true);
959
1259
  this.timers.push(setTimeout(() => {
960
- this.oc?.classList.remove("ring");
1260
+ this.oc?.classList.remove("show-ring");
961
1261
  }, 6e3));
962
1262
  this.armIdleTeaser();
963
1263
  }
@@ -1044,8 +1344,10 @@ var OyehelloChatElement = class extends HTMLElement {
1044
1344
  }
1045
1345
  disconnectedCallback() {
1046
1346
  this.off?.();
1047
- void this.client?.end();
1347
+ this.client?.detach();
1048
1348
  this.teardownIdle();
1349
+ this.uninstallSpaHooks?.();
1350
+ this.uninstallSpaHooks = null;
1049
1351
  for (const t of this.timers) clearTimeout(t);
1050
1352
  for (const url of this.imagePreviewCache.values()) {
1051
1353
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1123,6 +1425,7 @@ var OyehelloChatElement = class extends HTMLElement {
1123
1425
  this.oc = root.querySelector(".oc");
1124
1426
  this.bodyEl = root.querySelector(".body");
1125
1427
  this.textareaEl = root.querySelector("textarea.input");
1428
+ this.composerEl = root.querySelector(".composer");
1126
1429
  this.fileEl = root.querySelector('input[type="file"]');
1127
1430
  this.titleEl = root.querySelector(".hd-title");
1128
1431
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1181,18 +1484,22 @@ var OyehelloChatElement = class extends HTMLElement {
1181
1484
  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>`;
1182
1485
  }
1183
1486
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
1487
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
1488
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
1489
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
1490
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
1184
1491
  updateClasses(initialRing = false) {
1185
1492
  if (!this.oc) return;
1186
1493
  const c = this.oc.classList;
1187
1494
  c.toggle("open", this.uiOpen);
1188
1495
  c.toggle("max", this.uiMax);
1189
1496
  c.toggle("emoji", this.emojiOpen);
1190
- c.toggle("teaser", this.teaserActive);
1497
+ c.toggle("show-teaser", this.teaserActive);
1191
1498
  c.toggle("away", this.away);
1192
1499
  c.toggle("focus", this.focused);
1193
1500
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1194
1501
  c.toggle("unread", this.unseen > 0);
1195
- if (initialRing) c.add("ring");
1502
+ if (initialRing) c.add("show-ring");
1196
1503
  const badge = this.oc.querySelector(".badge");
1197
1504
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1198
1505
  const launcher = this.oc.querySelector(".launcher");
@@ -1261,6 +1568,7 @@ var OyehelloChatElement = class extends HTMLElement {
1261
1568
  if (!this.textareaEl) return;
1262
1569
  const disabled = this.state !== "ready" || this.offline;
1263
1570
  this.textareaEl.disabled = disabled;
1571
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1264
1572
  const handoff = HANDOFF_STATUS[this.handoffState];
1265
1573
  const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1266
1574
  if (this.statusEl) this.statusEl.textContent = text;