@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.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,6 +176,135 @@ 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)}`;
@@ -181,13 +327,95 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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 @@ function createChatClient(options) {
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, attachments: freshAttachments, fetchImpl });
@@ -415,18 +662,29 @@ function createChatClient(options) {
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";
@@ -517,6 +775,10 @@ var STYLES = `
517
775
  --oc-glass-border: rgba(9,13,20,0.12);
518
776
 
519
777
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
778
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
779
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
780
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
781
+ display: block; visibility: visible; opacity: 1;
520
782
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
521
783
  -webkit-font-smoothing: antialiased;
522
784
  }
@@ -556,7 +818,7 @@ var STYLES = `
556
818
  .launcher:hover { transform: scale(1.06); }
557
819
  .launcher:active { transform: scale(0.93); }
558
820
  .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; }
821
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
560
822
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
561
823
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
562
824
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -578,7 +840,7 @@ var STYLES = `
578
840
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
579
841
  }
580
842
  .oc.has-teaser .teaser { display: flex; }
581
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
843
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
582
844
  .teaser-btn {
583
845
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
584
846
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -675,6 +937,7 @@ var STYLES = `
675
937
 
676
938
  /* \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
939
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
940
+ .composer[hidden] { display: none; }
678
941
  .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
942
  .oc.focus .field { border-color: var(--oc-ac); }
680
943
  .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 +1178,14 @@ var OyehelloChatElement = class extends HTMLElement {
915
1178
  // widget-config resolved (so we know online/offline before starting)
916
1179
  idleTimer = null;
917
1180
  idleReset = null;
1181
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1182
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
1183
+ uninstallSpaHooks = null;
918
1184
  // ── shadow refs ──
919
1185
  oc;
920
1186
  bodyEl;
921
1187
  textareaEl;
1188
+ composerEl;
922
1189
  fileEl;
923
1190
  titleEl;
924
1191
  statusEl;
@@ -940,7 +1207,16 @@ var OyehelloChatElement = class extends HTMLElement {
940
1207
  this.pendingTools = {};
941
1208
  }
942
1209
  this.off = this.client.on((e) => this.onClientEvent(e));
1210
+ recordView(agentId, location.pathname);
1211
+ try {
1212
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
1213
+ } catch {
1214
+ this.uninstallSpaHooks = null;
1215
+ }
943
1216
  this.renderShell();
1217
+ this.style.setProperty("display", "block", "important");
1218
+ this.style.setProperty("visibility", "visible", "important");
1219
+ this.style.setProperty("opacity", "1", "important");
944
1220
  this.renderMessages();
945
1221
  this.updateHeader();
946
1222
  this.updateStatus();
@@ -957,7 +1233,7 @@ var OyehelloChatElement = class extends HTMLElement {
957
1233
  }
958
1234
  this.updateClasses(true);
959
1235
  this.timers.push(setTimeout(() => {
960
- this.oc?.classList.remove("ring");
1236
+ this.oc?.classList.remove("show-ring");
961
1237
  }, 6e3));
962
1238
  this.armIdleTeaser();
963
1239
  }
@@ -1044,8 +1320,10 @@ var OyehelloChatElement = class extends HTMLElement {
1044
1320
  }
1045
1321
  disconnectedCallback() {
1046
1322
  this.off?.();
1047
- void this.client?.end();
1323
+ this.client?.detach();
1048
1324
  this.teardownIdle();
1325
+ this.uninstallSpaHooks?.();
1326
+ this.uninstallSpaHooks = null;
1049
1327
  for (const t of this.timers) clearTimeout(t);
1050
1328
  for (const url of this.imagePreviewCache.values()) {
1051
1329
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1123,6 +1401,7 @@ var OyehelloChatElement = class extends HTMLElement {
1123
1401
  this.oc = root.querySelector(".oc");
1124
1402
  this.bodyEl = root.querySelector(".body");
1125
1403
  this.textareaEl = root.querySelector("textarea.input");
1404
+ this.composerEl = root.querySelector(".composer");
1126
1405
  this.fileEl = root.querySelector('input[type="file"]');
1127
1406
  this.titleEl = root.querySelector(".hd-title");
1128
1407
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1181,18 +1460,22 @@ var OyehelloChatElement = class extends HTMLElement {
1181
1460
  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
1461
  }
1183
1462
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
1463
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
1464
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
1465
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
1466
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
1184
1467
  updateClasses(initialRing = false) {
1185
1468
  if (!this.oc) return;
1186
1469
  const c = this.oc.classList;
1187
1470
  c.toggle("open", this.uiOpen);
1188
1471
  c.toggle("max", this.uiMax);
1189
1472
  c.toggle("emoji", this.emojiOpen);
1190
- c.toggle("teaser", this.teaserActive);
1473
+ c.toggle("show-teaser", this.teaserActive);
1191
1474
  c.toggle("away", this.away);
1192
1475
  c.toggle("focus", this.focused);
1193
1476
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1194
1477
  c.toggle("unread", this.unseen > 0);
1195
- if (initialRing) c.add("ring");
1478
+ if (initialRing) c.add("show-ring");
1196
1479
  const badge = this.oc.querySelector(".badge");
1197
1480
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1198
1481
  const launcher = this.oc.querySelector(".launcher");
@@ -1261,6 +1544,7 @@ var OyehelloChatElement = class extends HTMLElement {
1261
1544
  if (!this.textareaEl) return;
1262
1545
  const disabled = this.state !== "ready" || this.offline;
1263
1546
  this.textareaEl.disabled = disabled;
1547
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1264
1548
  const handoff = HANDOFF_STATUS[this.handoffState];
1265
1549
  const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1266
1550
  if (this.statusEl) this.statusEl.textContent = text;