@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.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,9 +176,161 @@ 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)}`;
311
+ var isSafeRelativePath = (p) => p.startsWith("/") && !p.startsWith("//");
312
+ var isSafeHttpUrl = (u3) => {
313
+ try {
314
+ const parsed = new URL(u3, 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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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: text2, attachments: freshAttachments, fetchImpl });
@@ -415,18 +686,29 @@ var OyehelloChat = (function (exports) {
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";
@@ -3397,6 +3679,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3397
3679
  --oc-glass-border: rgba(9,13,20,0.12);
3398
3680
 
3399
3681
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
3682
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
3683
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
3684
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
3685
+ display: block; visibility: visible; opacity: 1;
3400
3686
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
3401
3687
  -webkit-font-smoothing: antialiased;
3402
3688
  }
@@ -3436,7 +3722,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3436
3722
  .launcher:hover { transform: scale(1.06); }
3437
3723
  .launcher:active { transform: scale(0.93); }
3438
3724
  .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; }
3725
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
3440
3726
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
3441
3727
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
3442
3728
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -3458,7 +3744,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3458
3744
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
3459
3745
  }
3460
3746
  .oc.has-teaser .teaser { display: flex; }
3461
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
3747
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
3462
3748
  .teaser-btn {
3463
3749
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
3464
3750
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -3555,6 +3841,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3555
3841
 
3556
3842
  /* \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
3843
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
3844
+ .composer[hidden] { display: none; }
3558
3845
  .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
3846
  .oc.focus .field { border-color: var(--oc-ac); }
3560
3847
  .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 +4082,14 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3795
4082
  // widget-config resolved (so we know online/offline before starting)
3796
4083
  idleTimer = null;
3797
4084
  idleReset = null;
4085
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
4086
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
4087
+ uninstallSpaHooks = null;
3798
4088
  // ── shadow refs ──
3799
4089
  oc;
3800
4090
  bodyEl;
3801
4091
  textareaEl;
4092
+ composerEl;
3802
4093
  fileEl;
3803
4094
  titleEl;
3804
4095
  statusEl;
@@ -3820,7 +4111,16 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3820
4111
  this.pendingTools = {};
3821
4112
  }
3822
4113
  this.off = this.client.on((e) => this.onClientEvent(e));
4114
+ recordView(agentId, location.pathname);
4115
+ try {
4116
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
4117
+ } catch {
4118
+ this.uninstallSpaHooks = null;
4119
+ }
3823
4120
  this.renderShell();
4121
+ this.style.setProperty("display", "block", "important");
4122
+ this.style.setProperty("visibility", "visible", "important");
4123
+ this.style.setProperty("opacity", "1", "important");
3824
4124
  this.renderMessages();
3825
4125
  this.updateHeader();
3826
4126
  this.updateStatus();
@@ -3837,7 +4137,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3837
4137
  }
3838
4138
  this.updateClasses(true);
3839
4139
  this.timers.push(setTimeout(() => {
3840
- this.oc?.classList.remove("ring");
4140
+ this.oc?.classList.remove("show-ring");
3841
4141
  }, 6e3));
3842
4142
  this.armIdleTeaser();
3843
4143
  }
@@ -3924,8 +4224,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3924
4224
  }
3925
4225
  disconnectedCallback() {
3926
4226
  this.off?.();
3927
- void this.client?.end();
4227
+ this.client?.detach();
3928
4228
  this.teardownIdle();
4229
+ this.uninstallSpaHooks?.();
4230
+ this.uninstallSpaHooks = null;
3929
4231
  for (const t of this.timers) clearTimeout(t);
3930
4232
  for (const url of this.imagePreviewCache.values()) {
3931
4233
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -4003,6 +4305,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4003
4305
  this.oc = root.querySelector(".oc");
4004
4306
  this.bodyEl = root.querySelector(".body");
4005
4307
  this.textareaEl = root.querySelector("textarea.input");
4308
+ this.composerEl = root.querySelector(".composer");
4006
4309
  this.fileEl = root.querySelector('input[type="file"]');
4007
4310
  this.titleEl = root.querySelector(".hd-title");
4008
4311
  this.statusEl = root.querySelector(".hd-status .t");
@@ -4061,18 +4364,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4061
4364
  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
4365
  }
4063
4366
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
4367
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
4368
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
4369
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
4370
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
4064
4371
  updateClasses(initialRing = false) {
4065
4372
  if (!this.oc) return;
4066
4373
  const c = this.oc.classList;
4067
4374
  c.toggle("open", this.uiOpen);
4068
4375
  c.toggle("max", this.uiMax);
4069
4376
  c.toggle("emoji", this.emojiOpen);
4070
- c.toggle("teaser", this.teaserActive);
4377
+ c.toggle("show-teaser", this.teaserActive);
4071
4378
  c.toggle("away", this.away);
4072
4379
  c.toggle("focus", this.focused);
4073
4380
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
4074
4381
  c.toggle("unread", this.unseen > 0);
4075
- if (initialRing) c.add("ring");
4382
+ if (initialRing) c.add("show-ring");
4076
4383
  const badge = this.oc.querySelector(".badge");
4077
4384
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
4078
4385
  const launcher = this.oc.querySelector(".launcher");
@@ -4141,6 +4448,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4141
4448
  if (!this.textareaEl) return;
4142
4449
  const disabled = this.state !== "ready" || this.offline;
4143
4450
  this.textareaEl.disabled = disabled;
4451
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
4144
4452
  const handoff = HANDOFF_STATUS[this.handoffState];
4145
4453
  const text2 = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4146
4454
  if (this.statusEl) this.statusEl.textContent = text2;