@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/README.md CHANGED
@@ -74,23 +74,33 @@ await chat.sendMessage('Hello');
74
74
 
75
75
  ## Client tools (browser actions)
76
76
 
77
- An agent can call browser-side tools your page executes. Register a handler per tool; a tool
78
- reaches the agent only when it is (a) enabled for that agent in the console AND (b) registered here:
77
+ The SDK supports two browser-side tools: `navigate` (go to a path on the current site) and `openUrl`
78
+ (open a URL, by default in a new tab). Once a tool is enabled for the agent in the console **and** the
79
+ target passes the agent's navigate allowlist (also set in the console), the SDK executes it itself —
80
+ **no host integration required**:
81
+ ```html
82
+ <script type="module">import '@oyehello/chat';</script>
83
+ <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
84
+ ```
85
+ That's it — a plain embed with no JS wiring can navigate the visitor or open a link when the agent calls
86
+ the tool. `navigate` calls `window.location.assign(path)`; `openUrl` calls `window.open(url, '_blank')`
87
+ (pass `newTab: false` from the agent to open in the same tab instead).
88
+
89
+ A host page MAY still register its own `navigate`/`openUrl` handler to override the default behavior
90
+ (e.g. push through a client-side router instead of a full navigation):
79
91
  ```js
80
92
  const chat = createChatClient({ apiKey, agentId, baseUrl });
81
93
  chat.registerTools({
82
94
  navigate: ({ path }) => router.push(path),
83
- fillField: ({ selector, value }) => { /* your DOM logic */ },
84
95
  });
85
96
  // or on the web component: document.querySelector('oyehello-chat').registerTools({ … })
86
97
  ```
87
- Register handlers BEFORE `start()` — only tools registered at open are offered to the agent that session.
98
+ Register handlers BEFORE `start()`. A host-registered handler always takes priority over the SDK's
99
+ built-in; any other tool name has no handler and fails cleanly.
88
100
 
89
- > **Security.** `navigate`/`openUrl` targets can be constrained to an allowlist in the agent's console
90
- > settings. The other tools (`click`, `fillField`, `readPageContext`, `custom`) receive
91
- > model-generated arguments with no server-side restriction: **your handler is the trust boundary.**
92
- > Because a visitor can influence the conversation, treat these as capable of arbitrary DOM action —
93
- > validate selectors/values and scope what each handler can do before enabling them.
101
+ > **Security.** Navigation targets (both the SDK's built-in execution and a host-overridden handler)
102
+ > are validated against the agent's navigate allowlist **server-side** before the tool call ever
103
+ > reaches the browser — the SDK/host is not the trust boundary for where a visitor can be sent.
94
104
 
95
105
  ## Requirements
96
106
  - `api-key` is a **publishable** key (`ohk_pub_…`) created in the oyehello console.
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,9 +182,161 @@ 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)}`;
317
+ var isSafeRelativePath = (p) => p.startsWith("/") && !p.startsWith("//");
318
+ var isSafeHttpUrl = (u) => {
319
+ try {
320
+ const parsed = new URL(u, window.location.origin);
321
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
322
+ } catch {
323
+ return false;
324
+ }
325
+ };
326
+ var BUILTIN_TOOL_HANDLERS = {
327
+ navigate: (args) => {
328
+ const path = String(args.path ?? "");
329
+ if (!isSafeRelativePath(path)) return { ok: false, error: "unsafe navigate path" };
330
+ window.location.assign(path);
331
+ return { ok: true };
332
+ },
333
+ openUrl: (args) => {
334
+ const url = String(args.url ?? "");
335
+ if (!isSafeHttpUrl(url)) return { ok: false, error: "unsafe url" };
336
+ window.open(url, args.newTab === false ? "_self" : "_blank", "noopener,noreferrer");
337
+ return { ok: true };
338
+ }
339
+ };
171
340
  function createChatClient(options) {
172
341
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
173
342
  const ES = options.eventSource ?? globalThis.EventSource;
@@ -187,13 +356,95 @@ function createChatClient(options) {
187
356
  let closed = false;
188
357
  let warnedProtocol = false;
189
358
  let deviceFingerprint;
359
+ let refreshTimer = null;
190
360
  const toolHandlers = /* @__PURE__ */ new Map();
361
+ let journeyFlushChain = Promise.resolve();
362
+ let journeyFlushQueued = false;
363
+ const flushJourney = () => {
364
+ if (!session) return;
365
+ if (journeyFlushQueued) return;
366
+ journeyFlushQueued = true;
367
+ journeyFlushChain = journeyFlushChain.then(async () => {
368
+ journeyFlushQueued = false;
369
+ if (!session) return;
370
+ const pending = unsentViews(options.agentId);
371
+ if (pending.length === 0) return;
372
+ const s = session;
373
+ try {
374
+ await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
375
+ markFlushed(options.agentId, pending.length);
376
+ } catch {
377
+ }
378
+ });
379
+ };
191
380
  const clearReconnect = () => {
192
381
  if (reconnectTimer) {
193
382
  clearTimeout(reconnectTimer);
194
383
  reconnectTimer = null;
195
384
  }
196
385
  };
386
+ const clearRefresh = () => {
387
+ if (refreshTimer) {
388
+ clearTimeout(refreshTimer);
389
+ refreshTimer = null;
390
+ }
391
+ };
392
+ const scheduleRefresh = () => {
393
+ clearRefresh();
394
+ if (closed || !session) return;
395
+ const untilExpiry = Date.parse(session.expiresAt) - Date.now();
396
+ const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
397
+ refreshTimer = setTimeout(async () => {
398
+ if (closed || !session) return;
399
+ try {
400
+ const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
401
+ session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
402
+ persistSession(session);
403
+ scheduleRefresh();
404
+ } catch (err) {
405
+ const code = err?.code;
406
+ if (code === "session_ended" || code === "session_expired") clearStoredSession();
407
+ }
408
+ }, delay);
409
+ refreshTimer.unref?.();
410
+ };
411
+ const sessKey = `oyehello-chat-sess:${options.agentId}`;
412
+ const store2 = (() => {
413
+ try {
414
+ return globalThis.sessionStorage ?? null;
415
+ } catch {
416
+ return null;
417
+ }
418
+ })();
419
+ const loadStoredSession = () => {
420
+ if (!store2) return null;
421
+ try {
422
+ const raw = store2.getItem(sessKey);
423
+ if (!raw) return null;
424
+ const s = JSON.parse(raw);
425
+ if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
426
+ const exp = Date.parse(s.expiresAt);
427
+ if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
428
+ store2.removeItem(sessKey);
429
+ return null;
430
+ }
431
+ return s;
432
+ } catch {
433
+ return null;
434
+ }
435
+ };
436
+ const persistSession = (s) => {
437
+ try {
438
+ store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
439
+ } catch {
440
+ }
441
+ };
442
+ const clearStoredSession = () => {
443
+ try {
444
+ store2?.removeItem(sessKey);
445
+ } catch {
446
+ }
447
+ };
197
448
  const emit = (e) => {
198
449
  for (const l of listeners) l(e);
199
450
  };
@@ -244,7 +495,7 @@ function createChatClient(options) {
244
495
  } else if (ev.type === "tool_call") {
245
496
  if (seenCallIds.has(ev.callId)) return;
246
497
  seenCallIds.add(ev.callId);
247
- const handler = toolHandlers.get(ev.name);
498
+ const handler = toolHandlers.get(ev.name) ?? BUILTIN_TOOL_HANDLERS[ev.name];
248
499
  void (async () => {
249
500
  if (!session) return;
250
501
  const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
@@ -284,8 +535,10 @@ function createChatClient(options) {
284
535
  } else if (ev.type === "done") {
285
536
  closed = true;
286
537
  clearReconnect();
538
+ clearRefresh();
287
539
  es?.close();
288
540
  es = null;
541
+ clearStoredSession();
289
542
  setState("ended");
290
543
  }
291
544
  };
@@ -318,6 +571,7 @@ function createChatClient(options) {
318
571
  reconnectTimer = setTimeout(() => {
319
572
  openStream().catch((err) => {
320
573
  if (err instanceof ChatApiError && err.code === "session_expired") {
574
+ clearStoredSession();
321
575
  open().catch((e) => {
322
576
  emit({ type: "error", error: e });
323
577
  scheduleReconnect();
@@ -329,8 +583,19 @@ function createChatClient(options) {
329
583
  }, delay);
330
584
  };
331
585
  const open = async () => {
586
+ const resumed = loadStoredSession();
587
+ if (resumed) {
588
+ session = resumed;
589
+ emit({ type: "agent", agent: resumed.agent });
590
+ await openStream();
591
+ scheduleRefresh();
592
+ flushJourney();
593
+ return;
594
+ }
332
595
  if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
333
- 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 });
596
+ const advertisedTools = /* @__PURE__ */ new Set([...toolHandlers.keys(), ...Object.keys(BUILTIN_TOOL_HANDLERS)]);
597
+ session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(advertisedTools), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
598
+ persistSession(session);
334
599
  if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
335
600
  warnedProtocol = true;
336
601
  try {
@@ -340,13 +605,11 @@ function createChatClient(options) {
340
605
  }
341
606
  emit({ type: "agent", agent: session.agent });
342
607
  await openStream();
608
+ scheduleRefresh();
609
+ flushJourney();
343
610
  };
344
611
  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
- }));
612
+ return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
350
613
  };
351
614
  return {
352
615
  getMessages: () => messages.slice(),
@@ -365,6 +628,13 @@ function createChatClient(options) {
365
628
  getWidgetConfig() {
366
629
  return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
367
630
  },
631
+ recordPageView(path) {
632
+ try {
633
+ recordView(options.agentId, path);
634
+ flushJourney();
635
+ } catch {
636
+ }
637
+ },
368
638
  async start() {
369
639
  if (state === "opening" || state === "ready") return;
370
640
  closed = false;
@@ -410,6 +680,7 @@ function createChatClient(options) {
410
680
  } catch (err) {
411
681
  if (err instanceof ChatApiError && err.code === "session_expired") {
412
682
  try {
683
+ clearStoredSession();
413
684
  await open();
414
685
  const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
415
686
  await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
@@ -421,18 +692,29 @@ function createChatClient(options) {
421
692
  }
422
693
  }
423
694
  },
695
+ detach() {
696
+ closed = true;
697
+ clearReconnect();
698
+ clearRefresh();
699
+ es?.close();
700
+ es = null;
701
+ },
424
702
  async end() {
425
703
  closed = true;
426
704
  clearReconnect();
705
+ clearRefresh();
706
+ clearStoredSession();
427
707
  if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
428
708
  es?.close();
429
709
  es = null;
430
710
  setState("ended");
431
711
  },
432
712
  reset() {
713
+ clearStoredSession();
433
714
  es?.close();
434
715
  es = null;
435
716
  clearReconnect();
717
+ clearRefresh();
436
718
  session = null;
437
719
  streamingId = null;
438
720
  handoffState = "ai";
@@ -523,6 +805,10 @@ var STYLES = `
523
805
  --oc-glass-border: rgba(9,13,20,0.12);
524
806
 
525
807
  position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
808
+ /* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
809
+ * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
810
+ * inline with !important at mount (see element.ts) to beat an external !important rule. */
811
+ display: block; visibility: visible; opacity: 1;
526
812
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
527
813
  -webkit-font-smoothing: antialiased;
528
814
  }
@@ -562,7 +848,7 @@ var STYLES = `
562
848
  .launcher:hover { transform: scale(1.06); }
563
849
  .launcher:active { transform: scale(0.93); }
564
850
  .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; }
851
+ .oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
566
852
  .ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
567
853
  .ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
568
854
  .ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
@@ -584,7 +870,7 @@ var STYLES = `
584
870
  transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
585
871
  }
586
872
  .oc.has-teaser .teaser { display: flex; }
587
- .oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
873
+ .oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
588
874
  .teaser-btn {
589
875
  max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
590
876
  border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
@@ -681,6 +967,7 @@ var STYLES = `
681
967
 
682
968
  /* \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
969
  .composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
970
+ .composer[hidden] { display: none; }
684
971
  .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
972
  .oc.focus .field { border-color: var(--oc-ac); }
686
973
  .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 +1208,14 @@ var OyehelloChatElement = class extends HTMLElement {
921
1208
  // widget-config resolved (so we know online/offline before starting)
922
1209
  idleTimer = null;
923
1210
  idleReset = null;
1211
+ // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1212
+ // connectedCallback and torn down in disconnectedCallback alongside everything else.
1213
+ uninstallSpaHooks = null;
924
1214
  // ── shadow refs ──
925
1215
  oc;
926
1216
  bodyEl;
927
1217
  textareaEl;
1218
+ composerEl;
928
1219
  fileEl;
929
1220
  titleEl;
930
1221
  statusEl;
@@ -946,7 +1237,16 @@ var OyehelloChatElement = class extends HTMLElement {
946
1237
  this.pendingTools = {};
947
1238
  }
948
1239
  this.off = this.client.on((e) => this.onClientEvent(e));
1240
+ recordView(agentId, location.pathname);
1241
+ try {
1242
+ this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
1243
+ } catch {
1244
+ this.uninstallSpaHooks = null;
1245
+ }
949
1246
  this.renderShell();
1247
+ this.style.setProperty("display", "block", "important");
1248
+ this.style.setProperty("visibility", "visible", "important");
1249
+ this.style.setProperty("opacity", "1", "important");
950
1250
  this.renderMessages();
951
1251
  this.updateHeader();
952
1252
  this.updateStatus();
@@ -963,7 +1263,7 @@ var OyehelloChatElement = class extends HTMLElement {
963
1263
  }
964
1264
  this.updateClasses(true);
965
1265
  this.timers.push(setTimeout(() => {
966
- this.oc?.classList.remove("ring");
1266
+ this.oc?.classList.remove("show-ring");
967
1267
  }, 6e3));
968
1268
  this.armIdleTeaser();
969
1269
  }
@@ -1050,8 +1350,10 @@ var OyehelloChatElement = class extends HTMLElement {
1050
1350
  }
1051
1351
  disconnectedCallback() {
1052
1352
  this.off?.();
1053
- void this.client?.end();
1353
+ this.client?.detach();
1054
1354
  this.teardownIdle();
1355
+ this.uninstallSpaHooks?.();
1356
+ this.uninstallSpaHooks = null;
1055
1357
  for (const t of this.timers) clearTimeout(t);
1056
1358
  for (const url of this.imagePreviewCache.values()) {
1057
1359
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1129,6 +1431,7 @@ var OyehelloChatElement = class extends HTMLElement {
1129
1431
  this.oc = root.querySelector(".oc");
1130
1432
  this.bodyEl = root.querySelector(".body");
1131
1433
  this.textareaEl = root.querySelector("textarea.input");
1434
+ this.composerEl = root.querySelector(".composer");
1132
1435
  this.fileEl = root.querySelector('input[type="file"]');
1133
1436
  this.titleEl = root.querySelector(".hd-title");
1134
1437
  this.statusEl = root.querySelector(".hd-status .t");
@@ -1187,18 +1490,22 @@ var OyehelloChatElement = class extends HTMLElement {
1187
1490
  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
1491
  }
1189
1492
  // ── State-class sync ──────────────────────────────────────────────────────────────────────────
1493
+ // State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
1494
+ // (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
1495
+ // own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
1496
+ // widget. Hence the `show-` prefix on the teaser/ring state classes.
1190
1497
  updateClasses(initialRing = false) {
1191
1498
  if (!this.oc) return;
1192
1499
  const c = this.oc.classList;
1193
1500
  c.toggle("open", this.uiOpen);
1194
1501
  c.toggle("max", this.uiMax);
1195
1502
  c.toggle("emoji", this.emojiOpen);
1196
- c.toggle("teaser", this.teaserActive);
1503
+ c.toggle("show-teaser", this.teaserActive);
1197
1504
  c.toggle("away", this.away);
1198
1505
  c.toggle("focus", this.focused);
1199
1506
  c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
1200
1507
  c.toggle("unread", this.unseen > 0);
1201
- if (initialRing) c.add("ring");
1508
+ if (initialRing) c.add("show-ring");
1202
1509
  const badge = this.oc.querySelector(".badge");
1203
1510
  if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
1204
1511
  const launcher = this.oc.querySelector(".launcher");
@@ -1267,6 +1574,7 @@ var OyehelloChatElement = class extends HTMLElement {
1267
1574
  if (!this.textareaEl) return;
1268
1575
  const disabled = this.state !== "ready" || this.offline;
1269
1576
  this.textareaEl.disabled = disabled;
1577
+ if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
1270
1578
  const handoff = HANDOFF_STATUS[this.handoffState];
1271
1579
  const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1272
1580
  if (this.statusEl) this.statusEl.textContent = text;