@helpai/elements 1.8.0 → 1.10.0

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.
@@ -0,0 +1,483 @@
1
+ # Events & lifecycle hooks
2
+
3
+ Listen for chat interactions, forward them to analytics, trigger
4
+ custom UX.
5
+
6
+ ---
7
+
8
+ ## Two mechanisms
9
+
10
+ | Mechanism | Scope | When to use |
11
+ | ----------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
12
+ | **Instance events** — `chat.on(event, fn)` | One widget you hold a reference to | Custom UX wired to a specific widget |
13
+ | **Global hooks** — `HelpAI.hook(widgetId, event, fn)` | Any widget on the page (by id or wildcard) | Analytics, error reporting, cross-widget sinks |
14
+
15
+ Both deliver the same event stream. Both are safe to mix.
16
+
17
+ ---
18
+
19
+ ## Available events
20
+
21
+ | Event | Fires on | Payload |
22
+ | ----------------- | ------------------------------------------------- | ------------------------------------------------------ |
23
+ | `open` | Panel opens (floating mode) | `void` |
24
+ | `close` | Panel closes | `void` |
25
+ | `message` | User sends a message + assistant finishes a reply | `{ role: "user" \| "assistant"; text: string }` |
26
+ | `error` | Transport error or mid-stream error | `unknown` (error object or message) |
27
+ | `expand` | Expanded tier toggled (⤢) | `boolean` (new state) |
28
+ | `fullscreen` | Fullscreen tier toggled (⛶) | `boolean` (new state) |
29
+ | `handshake` | Widget finishes booting (once per mount) | `HandshakeResponse` (dashboard config) |
30
+ | `localeChange` | User picks a different language | `string` (new BCP-47 tag) |
31
+ | `messageFeedback` | User rates a reply 👍/👎 (or clears it) | `{ messageId: string; value: "up" \| "down" \| null }` |
32
+
33
+ ---
34
+
35
+ ## Instance events
36
+
37
+ If you hold the `ChatWidgetInstance` returned from `init()` / `mount()`,
38
+ subscribe with `.on()`:
39
+
40
+ ```ts
41
+ const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
42
+
43
+ const off = chat.on('message', (msg) => {
44
+ console.log(`[${msg.role}] ${msg.text}`);
45
+ });
46
+
47
+ // Later — unsubscribe
48
+ off();
49
+ ```
50
+
51
+ `.on()` returns an unsubscribe function. Call it when you don't need
52
+ the listener anymore (e.g., component unmount).
53
+
54
+ ### Inline callbacks
55
+
56
+ For one-off handlers, pass them directly to `init()`:
57
+
58
+ ```ts
59
+ init({
60
+ publicKey: 'agent_pk_...',
61
+ aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000',
62
+ onMessage: (m) => analytics.track('chat_msg', m),
63
+ onOpen: () => analytics.track('chat_open'),
64
+ onClose: () => analytics.track('chat_close'),
65
+ onError: (err) => Sentry.captureException(err),
66
+ });
67
+ ```
68
+
69
+ Equivalent to calling `.on()` immediately after mount but cleaner for
70
+ simple cases.
71
+
72
+ ### Typed payloads
73
+
74
+ Events are typed. In TypeScript, the payload is narrowed to the
75
+ correct shape:
76
+
77
+ ```ts
78
+ import type { EventMap } from '@helpai/elements';
79
+
80
+ chat.on('message', (m) => {
81
+ // m is typed as { role: string; text: string }
82
+ console.log(m.role, m.text);
83
+ });
84
+
85
+ chat.on('expand', (isExpanded) => {
86
+ // isExpanded is typed as boolean
87
+ if (isExpanded) console.log('expanded');
88
+ });
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Global lifecycle hooks
94
+
95
+ Different problem: you want to listen to events from **any** widget on
96
+ the page, possibly before the widget even loads.
97
+
98
+ ```ts
99
+ import { hook } from '@helpai/elements';
100
+
101
+ // Fire for every event on every widget
102
+ hook('*', '*', (instance, event, data) => {
103
+ analytics.track(`chat.${event}`, {
104
+ widgetId: instance.widgetId,
105
+ data,
106
+ });
107
+ });
108
+
109
+ // Fire only for 'message' on the "support" widget
110
+ hook('support', 'message', (instance, _event, data) => {
111
+ logSupportMessage(data);
112
+ });
113
+ ```
114
+
115
+ Wildcards (`"*"`) match any widget id or event name. Returns an
116
+ unsubscribe function.
117
+
118
+ ### Differences from `.on()`
119
+
120
+ | | `chat.on(event, fn)` | `hook(widgetId, event, fn)` |
121
+ | ------------------------- | -------------------- | ------------------------------------ |
122
+ | Scope | One widget | Any matching widget(s) |
123
+ | Callback signature | `(payload) => void` | `(instance, event, payload) => void` |
124
+ | Needs the instance handle | Yes | No — works with zero-JS embeds |
125
+ | Survives `destroy()` | No | Yes — registry is page-scoped |
126
+ | Pre-boot queueable | No | Yes |
127
+
128
+ ### Pre-boot hooks
129
+
130
+ If you use the async loader snippet, you can register hooks **before
131
+ the bundle arrives**. Push tuples onto `window.HelpAIHooks`:
132
+
133
+ ```html
134
+ <script>
135
+ window.HelpAIHooks = window.HelpAIHooks || [];
136
+
137
+ window.HelpAIHooks.push([
138
+ '*',
139
+ '*',
140
+ (inst, event, data) => {
141
+ analytics.track(`chat.${event}`, { widgetId: inst.widgetId, data });
142
+ },
143
+ ]);
144
+
145
+ window.HelpAIHooks.push([
146
+ 'support',
147
+ 'open',
148
+ (inst) => {
149
+ mixpanel.track('support_opened');
150
+ },
151
+ ]);
152
+ </script>
153
+ <!-- Widget loads below; hooks fire on its first event -->
154
+ <script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>
155
+ ```
156
+
157
+ Once the bundle loads, `window.HelpAIHooks` stays as a "live facade"
158
+ — you can keep pushing to it from lazily-loaded scripts and entries
159
+ register eagerly.
160
+
161
+ ---
162
+
163
+ ## Event catalog — detailed
164
+
165
+ ### `open`
166
+
167
+ Fires when the floating panel opens. Does NOT fire for inline /
168
+ standalone widgets (they're "always open").
169
+
170
+ ```ts
171
+ chat.on('open', () => {
172
+ document.body.classList.add('chat-open');
173
+ });
174
+ ```
175
+
176
+ ### `close`
177
+
178
+ Fires when the panel closes. In inline + fullscreen, fires `fullscreen:
179
+ false` instead (the Escape routes to "exit fullscreen").
180
+
181
+ ### `message`
182
+
183
+ Fires **twice per user turn**:
184
+
185
+ 1. On user send — `role: "user"`. Fires before the network round-trip.
186
+ 2. On assistant finish — `role: "assistant"`. Fires when the final
187
+ `finish` frame arrives (stream complete).
188
+
189
+ ```ts
190
+ chat.on('message', (m) => {
191
+ if (m.role === 'user') {
192
+ analytics.track('message_sent', { chars: m.text.length });
193
+ } else {
194
+ analytics.track('message_received', { chars: m.text.length });
195
+ }
196
+ });
197
+ ```
198
+
199
+ `text` is the **final assembled text** — not per-token. If you need
200
+ token-by-token, subscribe to signals directly (advanced).
201
+
202
+ ### `error`
203
+
204
+ Fires on transport errors, mid-stream errors, or reducer errors.
205
+ Payload is `unknown` — test its shape:
206
+
207
+ ```ts
208
+ chat.on('error', (err) => {
209
+ const message = err instanceof Error ? err.message : String(err);
210
+ console.error('Widget error:', message);
211
+ Sentry.captureException(err);
212
+ });
213
+ ```
214
+
215
+ The widget catches errors in listeners themselves — a throw inside
216
+ your handler won't break the widget or stop other listeners.
217
+
218
+ ### `expand`
219
+
220
+ Fires when the user toggles the ⤢ expanded tier. Payload is the new
221
+ state (`true` = entering expanded, `false` = leaving).
222
+
223
+ ```ts
224
+ chat.on('expand', (isExpanded) => {
225
+ if (isExpanded) trackConversationSignal('expanded-tier');
226
+ });
227
+ ```
228
+
229
+ Does NOT fire when the server-pushed `initialSize` auto-applies on
230
+ first mount.
231
+
232
+ ### `fullscreen`
233
+
234
+ Similar to `expand` but for the ⛶ fullscreen tier. Payload is the new
235
+ state.
236
+
237
+ ### `handshake`
238
+
239
+ Fires **once per mount** — after the widget finishes booting
240
+ (`/pai/handshake` resolves). Payload is the full bootstrap response
241
+ from the backend:
242
+
243
+ ```ts
244
+ chat.on('handshake', (res) => {
245
+ console.log('agent:', res.agent?.name);
246
+ console.log('welcome messages:', res.welcome?.messages);
247
+ console.log('server-pushed widget config:', res.config);
248
+ });
249
+ ```
250
+
251
+ Does NOT fire on "new conversation" re-connects. Only the very first
252
+ bootstrap.
253
+
254
+ ### `conversationRebound`
255
+
256
+ The server echoes the client-owned `visitorId`. If it replaces a client-minted
257
+ `conversationId` during the handshake or first-message acknowledgement, the
258
+ widget persists the replacement before firing this event.
259
+
260
+ ```ts
261
+ chat.on('conversationRebound', ({ previous, current }) => {
262
+ analytics.track('conversation-rebound', { previous, current });
263
+ });
264
+ ```
265
+
266
+ The common handshake path echoes the requested conversation id and does not
267
+ fire this event.
268
+
269
+ ### `localeChange`
270
+
271
+ Fires when the user picks a different language from the header
272
+ picker. Payload is the new BCP-47 tag.
273
+
274
+ ```ts
275
+ chat.on('localeChange', (locale) => {
276
+ // Sync the rest of the site's language
277
+ document.documentElement.lang = locale;
278
+ fetch('/api/user/preferences', {
279
+ method: 'POST',
280
+ body: JSON.stringify({ locale }),
281
+ });
282
+ });
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Common patterns
288
+
289
+ ### Send every event to Google Analytics 4
290
+
291
+ ```html
292
+ <script>
293
+ window.HelpAIHooks = window.HelpAIHooks || [];
294
+ window.HelpAIHooks.push([
295
+ '*',
296
+ '*',
297
+ (inst, event, data) => {
298
+ gtag('event', `chat_${event}`, {
299
+ widget_id: inst.widgetId,
300
+ payload: JSON.stringify(data).slice(0, 100),
301
+ });
302
+ },
303
+ ]);
304
+ </script>
305
+ <script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" defer></script>
306
+ ```
307
+
308
+ ### Forward to Segment
309
+
310
+ ```ts
311
+ import { hook } from '@helpai/elements';
312
+
313
+ hook('*', '*', (inst, event, data) => {
314
+ window.analytics.track(`Chat ${event}`, {
315
+ widgetId: inst.widgetId,
316
+ ...(typeof data === 'object' && data !== null ? data : {}),
317
+ });
318
+ });
319
+ ```
320
+
321
+ ### Capture errors in Sentry
322
+
323
+ ```ts
324
+ import { hook } from '@helpai/elements';
325
+ import * as Sentry from '@sentry/browser';
326
+
327
+ hook('*', 'error', (inst, _event, err) => {
328
+ Sentry.captureException(err, {
329
+ tags: { widget: inst.widgetId },
330
+ });
331
+ });
332
+ ```
333
+
334
+ ### Auto-close on inactivity
335
+
336
+ ```ts
337
+ const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
338
+
339
+ let timer;
340
+
341
+ chat.on('open', () => {
342
+ clearTimeout(timer);
343
+ timer = setTimeout(() => chat.close(), 5 * 60 * 1000); // 5 min
344
+ });
345
+
346
+ chat.on('message', () => {
347
+ clearTimeout(timer);
348
+ timer = setTimeout(() => chat.close(), 5 * 60 * 1000);
349
+ });
350
+
351
+ chat.on('close', () => clearTimeout(timer));
352
+ ```
353
+
354
+ ### Notify an intranet app when a visitor starts chatting
355
+
356
+ ```ts
357
+ chat.on('message', (m) => {
358
+ if (m.role === 'user') {
359
+ fetch('/api/internal/chat-alerts', {
360
+ method: 'POST',
361
+ body: JSON.stringify({
362
+ firstMessage: m.text,
363
+ userId: currentUser.id,
364
+ at: Date.now(),
365
+ }),
366
+ });
367
+ }
368
+ });
369
+ ```
370
+
371
+ ### Custom pulse on launcher when a new conversation is ready
372
+
373
+ ```ts
374
+ const chat = init({ publicKey: 'agent_pk_...', aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
375
+
376
+ chat.on('handshake', (res) => {
377
+ if (res.welcome?.messages?.length > 0) {
378
+ // Add a CSS class to your custom badge
379
+ document.querySelector('#chat-nudge').classList.add('has-new');
380
+ }
381
+ });
382
+ ```
383
+
384
+ ### React hook for message tracking
385
+
386
+ ```tsx
387
+ import { getInstance } from '@helpai/elements';
388
+ import { useEffect } from 'react';
389
+
390
+ export function useChatMessages(onMessage: (m: { role: string; text: string }) => void) {
391
+ useEffect(() => {
392
+ const chat = getInstance();
393
+ if (!chat) return;
394
+ return chat.on('message', onMessage);
395
+ }, [onMessage]);
396
+ }
397
+ ```
398
+
399
+ ### Instrument user's message lengths
400
+
401
+ ```ts
402
+ hook('*', 'message', (inst, _event, m) => {
403
+ if (m.role !== 'user') return;
404
+ const bucket = m.text.length < 20 ? 'short' : m.text.length < 100 ? 'medium' : 'long';
405
+ analytics.track('chat_message_length', { widgetId: inst.widgetId, bucket });
406
+ });
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Ordering guarantees
412
+
413
+ For a single event fired on a widget:
414
+
415
+ 1. Per-instance `.on()` listeners fire in registration order.
416
+ 2. Global `hook()` listeners fire in registration order.
417
+ 3. Errors in one listener don't break the others.
418
+
419
+ Per-event fire order in a typical turn:
420
+
421
+ ```
422
+ User types "hi", hits Enter
423
+ │
424
+ ├─ message (role: user, text: "hi") ← fires immediately, before network
425
+ │
426
+ │ [network round-trip, streaming begins]
427
+ │
428
+ └─ message (role: assistant, text: "...") ← fires on stream finish
429
+ ```
430
+
431
+ ---
432
+
433
+ ## Unsubscribe hygiene
434
+
435
+ Every subscription returns an `off()` function. Store and call them
436
+ on cleanup to avoid memory leaks:
437
+
438
+ ```ts
439
+ const offs = [chat.on('open', onOpen), chat.on('message', onMessage), chat.on('error', onError)];
440
+
441
+ // Cleanup (e.g., React component unmount)
442
+ for (const off of offs) off();
443
+ ```
444
+
445
+ React pattern:
446
+
447
+ ```tsx
448
+ useEffect(() => {
449
+ const chat = init({ publicKey, aiAgentDeploymentId: '01900000-0000-7000-8000-000000000000' });
450
+ const offs = [chat.on('message', onMessage), chat.on('error', onError)];
451
+ return () => {
452
+ for (const off of offs) off();
453
+ chat.destroy();
454
+ };
455
+ }, [publicKey]);
456
+ ```
457
+
458
+ `chat.destroy()` also clears all instance subscriptions — so the
459
+ explicit `off()` is belt-and-suspenders. Either way works.
460
+
461
+ ---
462
+
463
+ ## When listeners don't fire
464
+
465
+ | Symptom | Likely cause |
466
+ | ----------------------------------- | -------------------------------------------------------------------------------------------------------- |
467
+ | `open` never fires | Widget is in inline/standalone mode — it's "always open" |
468
+ | `handshake` never fires | Network error on `/pai/handshake`. `error` fires instead |
469
+ | `message` fires twice per user send | Correct — user first, then assistant reply |
470
+ | No events at all | `.on()` called before the instance was fully constructed. Use `init()`'s return value or a pre-boot hook |
471
+ | Pre-boot hook never fires | Widget never mounted — check the public-key/deployment-id pair |
472
+
473
+ [Troubleshooting](./troubleshooting.md) has the full diagnostic
474
+ checklist.
475
+
476
+ ---
477
+
478
+ ## Next
479
+
480
+ - [Analytics integration](./analytics.md) — GA4, Mixpanel, Segment, Amplitude, PostHog, Sentry, multi-provider
481
+ - [Recipes](./recipes.md) — copy-paste scenarios
482
+ - [API reference § events](./api-reference.md#events) — compact lookup
483
+ - [Configuration § callbacks](./configuration.md#callbacks-shortcut-for-events)