@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.cjs CHANGED
@@ -67,6 +67,15 @@ async function postMessage(opts) {
67
67
  });
68
68
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
69
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
+ }
70
79
  async function endSession(opts) {
71
80
  await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
72
81
  method: "POST",
@@ -84,6 +93,15 @@ async function postToolResult(opts) {
84
93
  }).catch(() => {
85
94
  });
86
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
+ }
87
105
  async function mintStreamTicket(opts) {
88
106
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
89
107
  method: "POST",
@@ -96,15 +114,18 @@ async function mintStreamTicket(opts) {
96
114
  function streamUrl(baseUrl, sessionId, ticket) {
97
115
  return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
98
116
  }
99
- async function presignAttachment(opts) {
100
- 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`, {
101
121
  method: "POST",
102
122
  credentials: "omit",
103
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
104
- body: JSON.stringify({ contentType: opts.contentType })
123
+ headers: { Authorization: `Bearer ${opts.sessionToken}` },
124
+ body: form
105
125
  });
106
126
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
107
- return await res.json();
127
+ const j = await res.json();
128
+ return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
108
129
  }
109
130
  async function getAttachmentUrl(opts) {
110
131
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
@@ -116,10 +137,6 @@ async function getAttachmentUrl(opts) {
116
137
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
117
138
  return (await res.json()).url;
118
139
  }
119
- async function uploadToPresigned(uploadUrl, file, fetchImpl) {
120
- const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
121
- if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
122
- }
123
140
 
124
141
  // src/fingerprint.ts
125
142
  async function sha256Hex(input) {
@@ -165,6 +182,135 @@ async function computeDeviceFingerprint() {
165
182
  }
166
183
  }
167
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
+ };
312
+ }
313
+
168
314
  // src/client.ts
169
315
  var seq = 0;
170
316
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -187,13 +333,95 @@ function createChatClient(options) {
187
333
  let closed = false;
188
334
  let warnedProtocol = false;
189
335
  let deviceFingerprint;
336
+ let refreshTimer = null;
190
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
+ };
191
357
  const clearReconnect = () => {
192
358
  if (reconnectTimer) {
193
359
  clearTimeout(reconnectTimer);
194
360
  reconnectTimer = null;
195
361
  }
196
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
+ };
197
425
  const emit = (e) => {
198
426
  for (const l of listeners) l(e);
199
427
  };
@@ -284,8 +512,10 @@ function createChatClient(options) {
284
512
  } else if (ev.type === "done") {
285
513
  closed = true;
286
514
  clearReconnect();
515
+ clearRefresh();
287
516
  es?.close();
288
517
  es = null;
518
+ clearStoredSession();
289
519
  setState("ended");
290
520
  }
291
521
  };
@@ -318,6 +548,7 @@ function createChatClient(options) {
318
548
  reconnectTimer = setTimeout(() => {
319
549
  openStream().catch((err) => {
320
550
  if (err instanceof ChatApiError && err.code === "session_expired") {
551
+ clearStoredSession();
321
552
  open().catch((e) => {
322
553
  emit({ type: "error", error: e });
323
554
  scheduleReconnect();
@@ -329,8 +560,18 @@ function createChatClient(options) {
329
560
  }, delay);
330
561
  };
331
562
  const open = async () => {
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
+ }
332
572
  if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
333
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);
334
575
  if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
335
576
  warnedProtocol = true;
336
577
  try {
@@ -340,13 +581,11 @@ function createChatClient(options) {
340
581
  }
341
582
  emit({ type: "agent", agent: session.agent });
342
583
  await openStream();
584
+ scheduleRefresh();
585
+ flushJourney();
343
586
  };
344
587
  const uploadFiles = async (files) => {
345
- return Promise.all(files.map(async (file) => {
346
- const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
347
- await uploadToPresigned(uploadUrl, file, fetchImpl);
348
- return { storageRef, contentType: file.type, name: file.name };
349
- }));
588
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
350
589
  };
351
590
  return {
352
591
  getMessages: () => messages.slice(),
@@ -365,6 +604,13 @@ function createChatClient(options) {
365
604
  getWidgetConfig() {
366
605
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
367
606
  },
607
+ recordPageView(path) {
608
+ try {
609
+ recordView(options.agentId, path);
610
+ flushJourney();
611
+ } catch {
612
+ }
613
+ },
368
614
  async start() {
369
615
  if (state === "opening" || state === "ready") return;
370
616
  closed = false;
@@ -410,6 +656,7 @@ function createChatClient(options) {
410
656
  } catch (err) {
411
657
  if (err instanceof ChatApiError && err.code === "session_expired") {
412
658
  try {
659
+ clearStoredSession();
413
660
  await open();
414
661
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
415
662
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
@@ -421,18 +668,29 @@ function createChatClient(options) {
421
668
  }
422
669
  }
423
670
  },
671
+ detach() {
672
+ closed = true;
673
+ clearReconnect();
674
+ clearRefresh();
675
+ es?.close();
676
+ es = null;
677
+ },
424
678
  async end() {
425
679
  closed = true;
426
680
  clearReconnect();
681
+ clearRefresh();
682
+ clearStoredSession();
427
683
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
428
684
  es?.close();
429
685
  es = null;
430
686
  setState("ended");
431
687
  },
432
688
  reset() {
689
+ clearStoredSession();
433
690
  es?.close();
434
691
  es = null;
435
692
  clearReconnect();
693
+ clearRefresh();
436
694
  session = null;
437
695
  streamingId = null;
438
696
  handoffState = "ai";
@@ -523,6 +781,10 @@ var STYLES = `
523
781
  --oc-glass-border: rgba(9,13,20,0.12);
524
782
 
525
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;
526
788
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
527
789
  -webkit-font-smoothing: antialiased;
528
790
  }
@@ -562,7 +824,7 @@ var STYLES = `
562
824
  .launcher:hover { transform: scale(1.06); }
563
825
  .launcher:active { transform: scale(0.93); }
564
826
  .ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
565
- .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; }
566
828
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
567
829
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
568
830
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -584,7 +846,7 @@ var STYLES = `
584
846
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
585
847
  }
586
848
  .oc.has-teaser .teaser { display: flex; }
587
- .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; }
588
850
  .teaser-btn {
589
851
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
590
852
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -681,6 +943,7 @@ var STYLES = `
681
943
 
682
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 */
683
945
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
946
+ .composer[hidden] { display: none; }
684
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); }
685
948
  .oc.focus .field { border-color: var(--oc-ac); }
686
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; }
@@ -921,10 +1184,14 @@ var OyehelloChatElement = class extends HTMLElement {
921
1184
  // widget-config resolved (so we know online/offline before starting)
922
1185
  idleTimer = null;
923
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;
924
1190
  // ── shadow refs ──
925
1191
  oc;
926
1192
  bodyEl;
927
1193
  textareaEl;
1194
+ composerEl;
928
1195
  fileEl;
929
1196
  titleEl;
930
1197
  statusEl;
@@ -946,7 +1213,16 @@ var OyehelloChatElement = class extends HTMLElement {
946
1213
  this.pendingTools = {};
947
1214
  }
948
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
+ }
949
1222
  this.renderShell();
1223
+ this.style.setProperty("display", "block", "important");
1224
+ this.style.setProperty("visibility", "visible", "important");
1225
+ this.style.setProperty("opacity", "1", "important");
950
1226
  this.renderMessages();
951
1227
  this.updateHeader();
952
1228
  this.updateStatus();
@@ -963,7 +1239,7 @@ var OyehelloChatElement = class extends HTMLElement {
963
1239
  }
964
1240
  this.updateClasses(true);
965
1241
  this.timers.push(setTimeout(() => {
966
- this.oc?.classList.remove("ring");
1242
+ this.oc?.classList.remove("show-ring");
967
1243
  }, 6e3));
968
1244
  this.armIdleTeaser();
969
1245
  }
@@ -1050,8 +1326,10 @@ var OyehelloChatElement = class extends HTMLElement {
1050
1326
  }
1051
1327
  disconnectedCallback() {
1052
1328
  this.off?.();
1053
- void this.client?.end();
1329
+ this.client?.detach();
1054
1330
  this.teardownIdle();
1331
+ this.uninstallSpaHooks?.();
1332
+ this.uninstallSpaHooks = null;
1055
1333
  for (const t of this.timers) clearTimeout(t);
1056
1334
  for (const url of this.imagePreviewCache.values()) {
1057
1335
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1129,6 +1407,7 @@ var OyehelloChatElement = class extends HTMLElement {
1129
1407
  this.oc = root.querySelector(".oc");
1130
1408
  this.bodyEl = root.querySelector(".body");
1131
1409
  this.textareaEl = root.querySelector("textarea.input");
1410
+ this.composerEl = root.querySelector(".composer");
1132
1411
  this.fileEl = root.querySelector('input[type="file"]');
1133
1412
  this.titleEl = root.querySelector(".hd-title");
1134
1413
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1187,18 +1466,22 @@ var OyehelloChatElement = class extends HTMLElement {
1187
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>`;
1188
1467
  }
1189
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.
1190
1473
  updateClasses(initialRing = false) {
1191
1474
  if (!this.oc) return;
1192
1475
  const c = this.oc.classList;
1193
1476
  c.toggle("open", this.uiOpen);
1194
1477
  c.toggle("max", this.uiMax);
1195
1478
  c.toggle("emoji", this.emojiOpen);
1196
- c.toggle("teaser", this.teaserActive);
1479
+ c.toggle("show-teaser", this.teaserActive);
1197
1480
  c.toggle("away", this.away);
1198
1481
  c.toggle("focus", this.focused);
1199
1482
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1200
1483
  c.toggle("unread", this.unseen > 0);
1201
- if (initialRing) c.add("ring");
1484
+ if (initialRing) c.add("show-ring");
1202
1485
  const badge = this.oc.querySelector(".badge");
1203
1486
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1204
1487
  const launcher = this.oc.querySelector(".launcher");
@@ -1267,6 +1550,7 @@ var OyehelloChatElement = class extends HTMLElement {
1267
1550
  if (!this.textareaEl) return;
1268
1551
  const disabled = this.state !== "ready" || this.offline;
1269
1552
  this.textareaEl.disabled = disabled;
1553
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1270
1554
  const handoff = HANDOFF_STATUS[this.handoffState];
1271
1555
  const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1272
1556
  if (this.statusEl) this.statusEl.textContent = text;