polymorph-sdk 0.2.4 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polymorph-sdk",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "src/index.ts",
@@ -7,6 +7,8 @@ interface IdentityFormProps {
7
7
  collectPhone: FieldRequirement;
8
8
  primaryColor: string;
9
9
  onSubmit: (user: WidgetUser) => void;
10
+ enableTermsAndConditions?: boolean;
11
+ termsAndConditionsText?: string;
10
12
  }
11
13
 
12
14
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
@@ -17,10 +19,13 @@ export function IdentityForm({
17
19
  collectPhone,
18
20
  primaryColor,
19
21
  onSubmit,
22
+ enableTermsAndConditions,
23
+ termsAndConditionsText,
20
24
  }: IdentityFormProps) {
21
25
  const [name, setName] = useState("");
22
26
  const [email, setEmail] = useState("");
23
27
  const [phone, setPhone] = useState("");
28
+ const [termsChecked, setTermsChecked] = useState(false);
24
29
  const [errors, setErrors] = useState<Record<string, string>>({});
25
30
 
26
31
  const validate = useCallback(() => {
@@ -45,8 +50,18 @@ export function IdentityForm({
45
50
  !phone.trim()
46
51
  )
47
52
  e.contact = "Please provide either an email or phone number";
53
+ if (enableTermsAndConditions && !termsChecked)
54
+ e.terms = "You must accept the terms and conditions";
48
55
  return e;
49
- }, [name, email, phone, collectEmail, collectPhone]);
56
+ }, [
57
+ name,
58
+ email,
59
+ phone,
60
+ collectEmail,
61
+ collectPhone,
62
+ enableTermsAndConditions,
63
+ termsChecked,
64
+ ]);
50
65
 
51
66
  const handleSubmit = useCallback(
52
67
  (ev: React.FormEvent) => {
@@ -123,6 +138,17 @@ export function IdentityForm({
123
138
  {errors.contact && (
124
139
  <div className={styles.formError}>{errors.contact}</div>
125
140
  )}
141
+ {enableTermsAndConditions && (
142
+ <label className={styles.termsLabel}>
143
+ <input
144
+ type="checkbox"
145
+ checked={termsChecked}
146
+ onChange={(ev) => setTermsChecked(ev.target.checked)}
147
+ />
148
+ <span>{termsAndConditionsText}</span>
149
+ </label>
150
+ )}
151
+ {errors.terms && <div className={styles.formError}>{errors.terms}</div>}
126
152
  <button
127
153
  type="submit"
128
154
  className={styles.formSubmitButton}
@@ -1,7 +1,8 @@
1
1
  import { LiveKitRoom } from "@livekit/components-react";
2
2
  import { MantineProvider } from "@mantine/core";
3
3
  import "@mantine/core/styles.css";
4
- import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { MediaDeviceFailure } from "livekit-client";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6
  import { RoomHandler } from "./RoomHandler";
6
7
  import styles from "./styles.module.css";
7
8
  import { buildWidgetTheme } from "./theme";
@@ -69,6 +70,17 @@ export function PolymorphWidget(props: WidgetConfig) {
69
70
  };
70
71
  }, [session.disconnect]);
71
72
 
73
+ const handleMediaDeviceFailure = useCallback(
74
+ (failure?: MediaDeviceFailure) => {
75
+ if (failure === MediaDeviceFailure.PermissionDenied) {
76
+ session.setError(
77
+ "Microphone access denied. Please allow mic access in your browser settings.",
78
+ );
79
+ }
80
+ },
81
+ [session.setError],
82
+ );
83
+
72
84
  const audio = useMemo(
73
85
  () =>
74
86
  session.isVoiceEnabled
@@ -125,6 +137,7 @@ export function PolymorphWidget(props: WidgetConfig) {
125
137
  video={false}
126
138
  style={{ display: "none" }}
127
139
  onDisconnected={session.disconnect}
140
+ onMediaDeviceFailure={handleMediaDeviceFailure}
128
141
  >
129
142
  <RoomHandler
130
143
  setRoom={session.setRoom}
@@ -1,5 +1,5 @@
1
1
  import type { Room } from "livekit-client";
2
- import { useCallback, useRef, useState } from "react";
2
+ import { type ChangeEvent, useCallback, useRef, useState } from "react";
3
3
  import { ChatThread } from "./ChatThread";
4
4
  import { IdentityForm } from "./IdentityForm";
5
5
  import styles from "./styles.module.css";
@@ -122,6 +122,8 @@ interface WidgetPanelProps {
122
122
  toggleScreenShare: () => void;
123
123
  setRoom: (room: Room | null) => void;
124
124
  setUser: (user: WidgetUser) => void;
125
+ needsTermsAcceptance: boolean;
126
+ acceptTerms: () => void;
125
127
  };
126
128
  onClose: () => void;
127
129
  hidden?: boolean;
@@ -190,7 +192,12 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
190
192
  collectEmail={session.identityCollection.collectEmail}
191
193
  collectPhone={session.identityCollection.collectPhone}
192
194
  primaryColor={primaryColor}
193
- onSubmit={session.setUser}
195
+ onSubmit={(user) => {
196
+ session.setUser(user);
197
+ if (rc?.enableTermsAndConditions) session.acceptTerms();
198
+ }}
199
+ enableTermsAndConditions={rc?.enableTermsAndConditions}
200
+ termsAndConditionsText={rc?.termsAndConditionsText}
194
201
  />
195
202
  ) : (
196
203
  <>
@@ -213,6 +220,19 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
213
220
  {session.error && (
214
221
  <div className={styles.errorText}>{session.error}</div>
215
222
  )}
223
+ {session.needsTermsAcceptance && rc?.termsAndConditionsText && (
224
+ <div className={styles.termsBar}>
225
+ <label className={styles.termsLabel}>
226
+ <input
227
+ type="checkbox"
228
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
229
+ if (e.target.checked) session.acceptTerms();
230
+ }}
231
+ />
232
+ <span>{rc.termsAndConditionsText}</span>
233
+ </label>
234
+ </div>
235
+ )}
216
236
  <div className={styles.inputBar}>
217
237
  <textarea
218
238
  ref={textareaRef}
@@ -226,14 +246,20 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
226
246
  onChange={(e) => setInputValue(e.target.value)}
227
247
  onInput={handleInput}
228
248
  onKeyDown={handleKeyDown}
229
- disabled={session.status === "connecting"}
249
+ disabled={
250
+ session.status === "connecting" || session.needsTermsAcceptance
251
+ }
230
252
  rows={1}
231
253
  />
232
254
  <button
233
255
  type="button"
234
256
  className={styles.iconButton}
235
257
  onClick={handleSend}
236
- disabled={!inputValue.trim() || session.status === "connecting"}
258
+ disabled={
259
+ !inputValue.trim() ||
260
+ session.status === "connecting" ||
261
+ session.needsTermsAcceptance
262
+ }
237
263
  >
238
264
  <SendIcon />
239
265
  </button>
@@ -25,6 +25,13 @@ function renderForm(
25
25
  return { onSubmit };
26
26
  }
27
27
 
28
+ function requireFormElement(element: HTMLFormElement | null): HTMLFormElement {
29
+ if (!element) {
30
+ throw new Error("Expected submit button to be inside a form");
31
+ }
32
+ return element;
33
+ }
34
+
28
35
  describe("IdentityForm", () => {
29
36
  // ── Rendering ──
30
37
 
@@ -88,7 +95,9 @@ describe("IdentityForm", () => {
88
95
  });
89
96
  // Use fireEvent.submit to bypass native <input type="email"> validation in jsdom
90
97
  fireEvent.submit(
91
- screen.getByRole("button", { name: "Start Chat" }).closest("form")!,
98
+ requireFormElement(
99
+ screen.getByRole("button", { name: "Start Chat" }).closest("form"),
100
+ ),
92
101
  );
93
102
  expect(screen.getByText("Invalid email format")).toBeInTheDocument();
94
103
  expect(onSubmit).not.toHaveBeenCalled();
@@ -45,6 +45,13 @@ const RESOLVE_RESPONSE = {
45
45
 
46
46
  let fetchMock: ReturnType<typeof vi.fn>;
47
47
 
48
+ function requireElement<T>(element: T | null | undefined, message: string): T {
49
+ if (!element) {
50
+ throw new Error(message);
51
+ }
52
+ return element;
53
+ }
54
+
48
55
  beforeEach(() => {
49
56
  fetchMock = vi.fn().mockImplementation((url: string) => {
50
57
  if (url.includes("/widget-configs/resolve")) {
@@ -77,7 +84,10 @@ describe("PolymorphWidget", () => {
77
84
 
78
85
  it("clicking FAB opens the panel", async () => {
79
86
  render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
80
- const fab = screen.getByTitle("Chat").closest("button")!;
87
+ const fab = requireElement(
88
+ screen.getByTitle("Chat").closest("button"),
89
+ "Expected chat button",
90
+ );
81
91
  fireEvent.click(fab);
82
92
  await waitFor(() => {
83
93
  expect(
@@ -88,7 +98,10 @@ describe("PolymorphWidget", () => {
88
98
 
89
99
  it("clicking FAB again closes the panel", async () => {
90
100
  render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
91
- const fab = screen.getByTitle("Chat").closest("button")!;
101
+ const fab = requireElement(
102
+ screen.getByTitle("Chat").closest("button"),
103
+ "Expected chat button",
104
+ );
92
105
 
93
106
  // Open
94
107
  fireEvent.click(fab);
@@ -102,8 +115,13 @@ describe("PolymorphWidget", () => {
102
115
  // (not the panel header close button) by its CSS class
103
116
  const fabButtons = screen
104
117
  .getAllByTitle("Close")
105
- .map((el) => el.closest("button")!);
106
- const fabBtn = fabButtons.find((btn) => btn.className.includes("fab"))!;
118
+ .map((el) =>
119
+ requireElement(el.closest("button"), "Expected close button"),
120
+ );
121
+ const fabBtn = requireElement(
122
+ fabButtons.find((btn) => btn.className.includes("fab")),
123
+ "Expected floating close button",
124
+ );
107
125
  fireEvent.click(fabBtn);
108
126
  await waitFor(() => {
109
127
  expect(
@@ -141,7 +159,10 @@ describe("PolymorphWidget", () => {
141
159
  expect(screen.getByText("Welcome!")).toBeInTheDocument();
142
160
  });
143
161
 
144
- const root = document.querySelector(".polymorph-widget")!;
162
+ const root = requireElement(
163
+ document.querySelector(".polymorph-widget"),
164
+ "Expected widget root",
165
+ );
145
166
  expect(root.className).toContain("bottomLeft");
146
167
  });
147
168
 
@@ -167,7 +188,10 @@ describe("PolymorphWidget", () => {
167
188
  expect(screen.getByText("Welcome!")).toBeInTheDocument();
168
189
  });
169
190
 
170
- const root = document.querySelector(".polymorph-widget")!;
171
- expect((root as HTMLElement).style.colorScheme).toBe("dark");
191
+ const root = requireElement(
192
+ document.querySelector(".polymorph-widget"),
193
+ "Expected widget root",
194
+ );
195
+ expect(root.style.colorScheme).toBe("dark");
172
196
  });
173
197
  });
@@ -14,6 +14,9 @@ const MOCK_CONFIG: ResolvedWidgetConfig = {
14
14
  greeting: "Hello!",
15
15
  collectEmail: "hidden",
16
16
  collectPhone: "hidden",
17
+ alwaysOn: false,
18
+ enableTermsAndConditions: false,
19
+ termsAndConditionsText: "",
17
20
  };
18
21
 
19
22
  function mockResolveResponse(cfg = MOCK_CONFIG) {
@@ -28,6 +31,9 @@ function mockResolveResponse(cfg = MOCK_CONFIG) {
28
31
  greeting: cfg.greeting,
29
32
  collect_email: cfg.collectEmail,
30
33
  collect_phone: cfg.collectPhone,
34
+ always_on: cfg.alwaysOn,
35
+ enable_terms_and_conditions: cfg.enableTermsAndConditions,
36
+ terms_and_conditions_text: cfg.termsAndConditionsText,
31
37
  };
32
38
  }
33
39
 
@@ -421,6 +421,25 @@
421
421
  outline-offset: 2px;
422
422
  }
423
423
 
424
+ .termsBar {
425
+ padding: 8px 16px;
426
+ border-top: 1px solid var(--mantine-color-default-border);
427
+ font-size: 12px;
428
+ color: var(--mantine-color-dimmed);
429
+ }
430
+ .termsLabel {
431
+ display: flex;
432
+ align-items: flex-start;
433
+ gap: 8px;
434
+ font-size: 12px;
435
+ line-height: 1.4;
436
+ cursor: pointer;
437
+ }
438
+ .termsLabel input[type="checkbox"] {
439
+ margin-top: 2px;
440
+ flex-shrink: 0;
441
+ }
442
+
424
443
  /* Mobile: full-width panel on small screens */
425
444
  @media (max-width: 420px) {
426
445
  .bottomRight,
package/src/types.ts CHANGED
@@ -14,6 +14,8 @@ export interface WidgetConfig {
14
14
  fetchOptions?: RequestInit;
15
15
  /** Called when a session starts with room info for constructing join URLs. */
16
16
  onSessionStart?: (info: { roomId: string; joinToken: string }) => void;
17
+ /** Auto-capture behavioral events (page_view, idle, rage_click). Defaults to true. */
18
+ autoCapture?: boolean;
17
19
  }
18
20
 
19
21
  export interface WidgetUser {
@@ -37,6 +39,9 @@ export interface ResolvedWidgetConfig {
37
39
  greeting: string;
38
40
  collectEmail: FieldRequirement;
39
41
  collectPhone: FieldRequirement;
42
+ alwaysOn: boolean;
43
+ enableTermsAndConditions: boolean;
44
+ termsAndConditionsText: string;
40
45
  }
41
46
 
42
47
  export type FieldRequirement = "required" | "optional" | "hidden";
@@ -57,3 +62,22 @@ export interface ChatMessage {
57
62
  }
58
63
 
59
64
  export type SessionStatus = "idle" | "connecting" | "connected" | "error";
65
+
66
+ export type WidgetEventType =
67
+ | "page_view"
68
+ | "click"
69
+ | "form_start"
70
+ | "form_abandon"
71
+ | "error_seen"
72
+ | "idle"
73
+ | "rage_click"
74
+ | "scroll_depth"
75
+ | "custom";
76
+
77
+ export interface WidgetEvent {
78
+ type: WidgetEventType;
79
+ payload?: Record<string, unknown>;
80
+ url?: string;
81
+ element?: string;
82
+ timestamp: number;
83
+ }
@@ -6,6 +6,8 @@ import type {
6
6
  ResolvedWidgetConfig,
7
7
  SessionStatus,
8
8
  WidgetConfig,
9
+ WidgetEvent,
10
+ WidgetEventType,
9
11
  WidgetUser,
10
12
  } from "./types";
11
13
 
@@ -21,6 +23,20 @@ function resolveApiBaseUrl(configured?: string): string {
21
23
  return "https://api.usepolymorph.com";
22
24
  }
23
25
 
26
+ function uuid(): string {
27
+ if (
28
+ typeof crypto !== "undefined" &&
29
+ typeof crypto.randomUUID === "function"
30
+ ) {
31
+ // eslint-disable-next-line no-restricted-globals
32
+ return crypto.randomUUID();
33
+ }
34
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
35
+ const r = (Math.random() * 16) | 0;
36
+ return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
37
+ });
38
+ }
39
+
24
40
  function storageKey(base: string, scope?: string): string {
25
41
  return scope ? `${base}:${scope}` : base;
26
42
  }
@@ -37,6 +53,17 @@ function loadStoredIdentity(scope?: string): WidgetUser | undefined {
37
53
  return undefined;
38
54
  }
39
55
 
56
+ function loadTermsAccepted(scope?: string): boolean {
57
+ try {
58
+ return (
59
+ localStorage.getItem(storageKey("polymorph_tc_accepted", scope)) ===
60
+ "true"
61
+ );
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
40
67
  interface RoomConnection {
41
68
  token: string;
42
69
  livekitUrl: string;
@@ -87,6 +114,7 @@ function buildAuthHeaders(
87
114
 
88
115
  export function usePolymorphSession(config: WidgetConfig) {
89
116
  const apiBaseUrl = resolveApiBaseUrl(config.apiBaseUrl);
117
+ const autoCapture = config.autoCapture ?? true;
90
118
  // Scope storage keys so multiple widget instances don't collide.
91
119
  const scope = config.configId || config.apiKey;
92
120
  const [status, setStatus] = useState<SessionStatus>("idle");
@@ -106,8 +134,10 @@ export function usePolymorphSession(config: WidgetConfig) {
106
134
  const [resolvedUser, setResolvedUser] = useState<WidgetUser | undefined>(
107
135
  config.user ?? loadStoredIdentity(scope),
108
136
  );
137
+ const [termsAccepted, setTermsAccepted] = useState(loadTermsAccepted(scope));
109
138
  const roomRef = useRef<Room | null>(null);
110
139
  const pendingMessagesRef = useRef<string[]>([]);
140
+ const pendingEventsRef = useRef<WidgetEvent[]>([]);
111
141
  const isPanelOpenRef = useRef<boolean>(false);
112
142
  // Stable ref for fetchOptions to avoid infinite useEffect loops
113
143
  // (inline objects like { credentials: "include" } create new refs each render)
@@ -154,6 +184,9 @@ export function usePolymorphSession(config: WidgetConfig) {
154
184
  greeting: data.greeting,
155
185
  collectEmail: data.collect_email,
156
186
  collectPhone: data.collect_phone,
187
+ alwaysOn: data.always_on ?? false,
188
+ enableTermsAndConditions: data.enable_terms_and_conditions ?? false,
189
+ termsAndConditionsText: data.terms_and_conditions_text ?? "",
157
190
  };
158
191
  setResolvedConfig(cfg);
159
192
 
@@ -231,6 +264,44 @@ export function usePolymorphSession(config: WidgetConfig) {
231
264
  [scope],
232
265
  );
233
266
 
267
+ const acceptTerms = useCallback(() => {
268
+ setTermsAccepted(true);
269
+ try {
270
+ localStorage.setItem(storageKey("polymorph_tc_accepted", scope), "true");
271
+ } catch {
272
+ /* quota exceeded */
273
+ }
274
+ }, [scope]);
275
+
276
+ const needsTermsAcceptance =
277
+ resolvedConfig?.enableTermsAndConditions === true && !termsAccepted;
278
+
279
+ // Browser tab notification for unread messages
280
+ const originalTitleRef = useRef(
281
+ typeof document !== "undefined" ? document.title : "",
282
+ );
283
+ const [unreadCount, setUnreadCount] = useState(0);
284
+
285
+ useEffect(() => {
286
+ if (unreadCount === 0) {
287
+ document.title = originalTitleRef.current;
288
+ return;
289
+ }
290
+ const base = originalTitleRef.current;
291
+ let showNotification = true;
292
+ document.title = `(${unreadCount}) New message${unreadCount > 1 ? "s" : ""} - ${base}`;
293
+ const interval = setInterval(() => {
294
+ showNotification = !showNotification;
295
+ document.title = showNotification
296
+ ? `(${unreadCount}) New message${unreadCount > 1 ? "s" : ""} - ${base}`
297
+ : base;
298
+ }, 1500);
299
+ return () => {
300
+ clearInterval(interval);
301
+ document.title = base;
302
+ };
303
+ }, [unreadCount]);
304
+
234
305
  const addMessage = useCallback(
235
306
  (
236
307
  role: "user" | "agent",
@@ -242,11 +313,12 @@ export function usePolymorphSession(config: WidgetConfig) {
242
313
  if (role === "agent" && !isPanelOpenRef.current) {
243
314
  setHasUnread(true);
244
315
  setWiggleKey((k) => k + 1);
316
+ setUnreadCount((c) => c + 1);
245
317
  }
246
318
  setMessages((prev) => [
247
319
  ...prev,
248
320
  {
249
- id: crypto.randomUUID(),
321
+ id: uuid(),
250
322
  role,
251
323
  text,
252
324
  source,
@@ -261,12 +333,14 @@ export function usePolymorphSession(config: WidgetConfig) {
261
333
 
262
334
  const clearUnread = useCallback(() => {
263
335
  setHasUnread(false);
336
+ setUnreadCount(0);
264
337
  }, []);
265
338
 
266
339
  const setPanelOpen = useCallback((open: boolean) => {
267
340
  isPanelOpenRef.current = open;
268
341
  if (open) {
269
342
  setHasUnread(false);
343
+ setUnreadCount(0);
270
344
  }
271
345
  }, []);
272
346
 
@@ -292,7 +366,7 @@ export function usePolymorphSession(config: WidgetConfig) {
292
366
  const sessionKey = storageKey("polymorph_widget_session", scope);
293
367
  let externalUserId = localStorage.getItem(sessionKey) ?? undefined;
294
368
  if (!externalUserId) {
295
- externalUserId = `widget-session-${crypto.randomUUID().slice(0, 12)}`;
369
+ externalUserId = `widget-session-${uuid().slice(0, 12)}`;
296
370
  localStorage.setItem(sessionKey, externalUserId);
297
371
  }
298
372
 
@@ -339,6 +413,13 @@ export function usePolymorphSession(config: WidgetConfig) {
339
413
  }
340
414
  }, [config, status, resolvedUser, resolvedConfig, apiBaseUrl, scope]);
341
415
 
416
+ // Auto-connect when always-on mode is enabled
417
+ useEffect(() => {
418
+ if (resolvedConfig?.alwaysOn && status === "idle") {
419
+ void connect();
420
+ }
421
+ }, [resolvedConfig?.alwaysOn, status, connect]);
422
+
342
423
  const disconnect = useCallback(() => {
343
424
  roomRef.current?.disconnect();
344
425
  roomRef.current = null;
@@ -380,8 +461,12 @@ export function usePolymorphSession(config: WidgetConfig) {
380
461
  const newState = !room.localParticipant.isMicrophoneEnabled;
381
462
  await room.localParticipant.setMicrophoneEnabled(newState);
382
463
  setIsMicActive(newState);
383
- } catch {
384
- /* mic toggle failed ignore */
464
+ } catch (err) {
465
+ if (err instanceof DOMException && err.name === "NotAllowedError") {
466
+ setError(
467
+ "Microphone access denied. Please allow mic access in your browser settings.",
468
+ );
469
+ }
385
470
  }
386
471
  }, []);
387
472
 
@@ -406,8 +491,14 @@ export function usePolymorphSession(config: WidgetConfig) {
406
491
  reliable: true,
407
492
  topic: "voice_mode",
408
493
  });
409
- } catch {
410
- /* voice toggle failed ignore */
494
+ } catch (err) {
495
+ if (err instanceof DOMException && err.name === "NotAllowedError") {
496
+ setError(
497
+ "Microphone access denied. Please allow mic access in your browser settings.",
498
+ );
499
+ setIsVoiceEnabled(false);
500
+ setIsMicActive(false);
501
+ }
411
502
  }
412
503
  }, [isVoiceEnabled, connect]);
413
504
 
@@ -423,6 +514,76 @@ export function usePolymorphSession(config: WidgetConfig) {
423
514
  }
424
515
  }, []);
425
516
 
517
+ const emitEvent = useCallback(
518
+ (type: WidgetEventType, payload?: Record<string, unknown>) => {
519
+ const event: WidgetEvent = {
520
+ type,
521
+ payload,
522
+ url: typeof window !== "undefined" ? window.location.href : undefined,
523
+ timestamp: Date.now(),
524
+ };
525
+ const room = roomRef.current;
526
+ if (room) {
527
+ const data = new TextEncoder().encode(JSON.stringify(event));
528
+ room.localParticipant.publishData(data, {
529
+ reliable: true,
530
+ topic: "widget_event",
531
+ });
532
+ } else {
533
+ pendingEventsRef.current.push(event);
534
+ }
535
+ },
536
+ [],
537
+ );
538
+
539
+ // Auto-capture: page_view on mount
540
+ useEffect(() => {
541
+ if (!autoCapture) return;
542
+ emitEvent("page_view", {
543
+ referrer: typeof document !== "undefined" ? document.referrer : "",
544
+ });
545
+ }, [autoCapture, emitEvent]);
546
+
547
+ // Auto-capture: idle detection (30s no interaction)
548
+ useEffect(() => {
549
+ if (!autoCapture) return;
550
+ let timer: ReturnType<typeof setTimeout>;
551
+ const reset = () => {
552
+ clearTimeout(timer);
553
+ timer = setTimeout(() => emitEvent("idle"), 30_000);
554
+ };
555
+ const events = ["mousemove", "keydown", "click", "scroll", "touchstart"];
556
+ for (const e of events)
557
+ window.addEventListener(e, reset, { passive: true });
558
+ reset();
559
+ return () => {
560
+ clearTimeout(timer);
561
+ for (const e of events) window.removeEventListener(e, reset);
562
+ };
563
+ }, [autoCapture, emitEvent]);
564
+
565
+ // Auto-capture: rage click (3+ clicks on same target within 1s)
566
+ useEffect(() => {
567
+ if (!autoCapture) return;
568
+ const clicks: { target: EventTarget | null; time: number }[] = [];
569
+ const onClick = (e: MouseEvent) => {
570
+ const now = Date.now();
571
+ clicks.push({ target: e.target, time: now });
572
+ while (clicks.length > 0 && now - clicks[0].time > 1000) clicks.shift();
573
+ const sameTarget = clicks.filter((c) => c.target === e.target);
574
+ if (sameTarget.length >= 3) {
575
+ const el = e.target as HTMLElement | null;
576
+ emitEvent("rage_click", {
577
+ element: el?.tagName?.toLowerCase(),
578
+ text: el?.textContent?.slice(0, 100),
579
+ });
580
+ clicks.length = 0;
581
+ }
582
+ };
583
+ window.addEventListener("click", onClick);
584
+ return () => window.removeEventListener("click", onClick);
585
+ }, [autoCapture, emitEvent]);
586
+
426
587
  const setRoom = useCallback((room: Room | null) => {
427
588
  roomRef.current = room;
428
589
  if (room) {
@@ -439,6 +600,16 @@ export function usePolymorphSession(config: WidgetConfig) {
439
600
  room.on(RoomEvent.ParticipantConnected, checkObserver);
440
601
  room.on(RoomEvent.ParticipantDisconnected, checkObserver);
441
602
 
603
+ // Flush pending widget events
604
+ const pendingEvents = pendingEventsRef.current.splice(0);
605
+ for (const event of pendingEvents) {
606
+ const data = new TextEncoder().encode(JSON.stringify(event));
607
+ room.localParticipant.publishData(data, {
608
+ reliable: true,
609
+ topic: "widget_event",
610
+ });
611
+ }
612
+
442
613
  // Flush pending messages once the agent participant has joined the room.
443
614
  // LiveKit data messages are only delivered to participants currently in
444
615
  // the room, so sending before the agent connects means messages are lost.
@@ -498,5 +669,9 @@ export function usePolymorphSession(config: WidgetConfig) {
498
669
  setUser,
499
670
  clearUnread,
500
671
  setPanelOpen,
672
+ emitEvent,
673
+ setError,
674
+ needsTermsAcceptance,
675
+ acceptTerms,
501
676
  };
502
677
  }