polymorph-sdk 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "src/index.ts",
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import styles from "./styles.module.css";
3
3
  import type { ChatMessage, SessionStatus } from "./types";
4
4
 
@@ -12,8 +12,19 @@ export function ChatThread({
12
12
  status: SessionStatus;
13
13
  }) {
14
14
  const scrollRef = useRef<HTMLDivElement>(null);
15
+ const isNearBottom = useRef(true);
16
+ const prevItemCount = useRef(0);
15
17
  const agentCountAtConnect = useRef<number | null>(null);
16
18
  const agentMessageCount = messages.filter((m) => m.role === "agent").length;
19
+ const lastMessageIsUser =
20
+ messages.length > 0 && messages[messages.length - 1].role === "user";
21
+
22
+ const handleScroll = useCallback(() => {
23
+ const el = scrollRef.current;
24
+ if (!el) return;
25
+ isNearBottom.current =
26
+ el.scrollHeight - el.scrollTop - el.clientHeight < 100;
27
+ }, []);
17
28
 
18
29
  // Snapshot agent message count when connecting starts
19
30
  if (status === "connecting" && agentCountAtConnect.current === null) {
@@ -33,23 +44,45 @@ export function ChatThread({
33
44
  messages[messages.length - 1].role === "user";
34
45
  const showThinking = initialConnectThinking || waitingForAgentReply;
35
46
 
36
- useEffect(() => {
37
- scrollRef.current?.scrollTo({
38
- top: scrollRef.current.scrollHeight,
39
- behavior: "smooth",
40
- });
41
- }, []);
47
+ // Auto-scroll when new messages arrive or thinking indicator appears.
48
+ // Uses render-time comparison instead of useEffect to avoid lint issues
49
+ // with intentional "trigger" dependencies.
50
+ const itemCount = messages.length + (showThinking ? 1 : 0);
51
+ if (itemCount !== prevItemCount.current) {
52
+ prevItemCount.current = itemCount;
53
+ if (isNearBottom.current || lastMessageIsUser) {
54
+ queueMicrotask(() => {
55
+ scrollRef.current?.scrollTo({
56
+ top: scrollRef.current.scrollHeight,
57
+ behavior: "smooth",
58
+ });
59
+ });
60
+ }
61
+ }
42
62
 
43
63
  return (
44
- <div ref={scrollRef} className={styles.chatThread}>
64
+ <div
65
+ ref={scrollRef}
66
+ className={styles.chatThread}
67
+ onScroll={handleScroll}
68
+ aria-live="polite"
69
+ aria-relevant="additions"
70
+ >
45
71
  {messages.map((msg) => (
46
72
  <div
47
73
  key={msg.id}
48
74
  className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
49
75
  style={
50
- msg.role === "user" ? { backgroundColor: primaryColor } : undefined
76
+ msg.role === "user"
77
+ ? { backgroundColor: primaryColor }
78
+ : msg.senderType === "human"
79
+ ? { backgroundColor: `${primaryColor}20` }
80
+ : undefined
51
81
  }
52
82
  >
83
+ {msg.senderName && (
84
+ <div className={styles.senderLabel}>{msg.senderName}</div>
85
+ )}
53
86
  {msg.text}
54
87
  {msg.source === "voice" && (
55
88
  <div className={styles.voiceLabel}>voice</div>
@@ -0,0 +1,135 @@
1
+ import { useCallback, useState } from "react";
2
+ import styles from "./styles.module.css";
3
+ import type { FieldRequirement, WidgetUser } from "./types";
4
+
5
+ interface IdentityFormProps {
6
+ collectEmail: FieldRequirement;
7
+ collectPhone: FieldRequirement;
8
+ primaryColor: string;
9
+ onSubmit: (user: WidgetUser) => void;
10
+ }
11
+
12
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
13
+ const PHONE_DIGITS_RE = /\d/g;
14
+
15
+ export function IdentityForm({
16
+ collectEmail,
17
+ collectPhone,
18
+ primaryColor,
19
+ onSubmit,
20
+ }: IdentityFormProps) {
21
+ const [name, setName] = useState("");
22
+ const [email, setEmail] = useState("");
23
+ const [phone, setPhone] = useState("");
24
+ const [errors, setErrors] = useState<Record<string, string>>({});
25
+
26
+ const validate = useCallback(() => {
27
+ const e: Record<string, string> = {};
28
+ if (!name.trim()) e.name = "Name is required";
29
+ if (collectEmail === "required" && !email.trim())
30
+ e.email = "Email is required";
31
+ if (email.trim() && !EMAIL_RE.test(email.trim()))
32
+ e.email = "Invalid email format";
33
+ if (collectPhone === "required" && !phone.trim())
34
+ e.phone = "Phone is required";
35
+ if (phone.trim()) {
36
+ const digitCount = (phone.trim().match(PHONE_DIGITS_RE) || []).length;
37
+ if (digitCount < 7 || digitCount > 15)
38
+ e.phone = "Enter a valid phone number (7–15 digits)";
39
+ }
40
+ // "Either" mode: both optional, at least one required
41
+ if (
42
+ collectEmail === "optional" &&
43
+ collectPhone === "optional" &&
44
+ !email.trim() &&
45
+ !phone.trim()
46
+ )
47
+ e.contact = "Please provide either an email or phone number";
48
+ return e;
49
+ }, [name, email, phone, collectEmail, collectPhone]);
50
+
51
+ const handleSubmit = useCallback(
52
+ (ev: React.FormEvent) => {
53
+ ev.preventDefault();
54
+ const e = validate();
55
+ if (Object.keys(e).length > 0) {
56
+ setErrors(e);
57
+ return;
58
+ }
59
+ onSubmit({
60
+ name: name.trim(),
61
+ ...(email.trim() && { email: email.trim() }),
62
+ ...(phone.trim() && { phone: phone.trim() }),
63
+ });
64
+ },
65
+ [validate, onSubmit, name, email, phone],
66
+ );
67
+
68
+ return (
69
+ <form className={styles.identityForm} onSubmit={handleSubmit}>
70
+ <label className={styles.formField}>
71
+ <span className={styles.formLabel}>
72
+ Name <span className={styles.formError}>*</span>
73
+ </span>
74
+ <input
75
+ className={styles.formInput}
76
+ type="text"
77
+ value={name}
78
+ onChange={(ev) => setName(ev.target.value)}
79
+ placeholder="Your name"
80
+ />
81
+ {errors.name && <div className={styles.formError}>{errors.name}</div>}
82
+ </label>
83
+ {collectEmail !== "hidden" && (
84
+ <label className={styles.formField}>
85
+ <span className={styles.formLabel}>
86
+ Email{" "}
87
+ {collectEmail === "required" && (
88
+ <span className={styles.formError}>*</span>
89
+ )}
90
+ </span>
91
+ <input
92
+ className={styles.formInput}
93
+ type="email"
94
+ value={email}
95
+ onChange={(ev) => setEmail(ev.target.value)}
96
+ placeholder="you@example.com"
97
+ />
98
+ {errors.email && (
99
+ <div className={styles.formError}>{errors.email}</div>
100
+ )}
101
+ </label>
102
+ )}
103
+ {collectPhone !== "hidden" && (
104
+ <label className={styles.formField}>
105
+ <span className={styles.formLabel}>
106
+ Phone{" "}
107
+ {collectPhone === "required" && (
108
+ <span className={styles.formError}>*</span>
109
+ )}
110
+ </span>
111
+ <input
112
+ className={styles.formInput}
113
+ type="tel"
114
+ value={phone}
115
+ onChange={(ev) => setPhone(ev.target.value)}
116
+ placeholder="+1 (555) 123-4567"
117
+ />
118
+ {errors.phone && (
119
+ <div className={styles.formError}>{errors.phone}</div>
120
+ )}
121
+ </label>
122
+ )}
123
+ {errors.contact && (
124
+ <div className={styles.formError}>{errors.contact}</div>
125
+ )}
126
+ <button
127
+ type="submit"
128
+ className={styles.formSubmitButton}
129
+ style={{ background: primaryColor }}
130
+ >
131
+ Start Chat
132
+ </button>
133
+ </form>
134
+ );
135
+ }
@@ -1,7 +1,7 @@
1
1
  import { LiveKitRoom } from "@livekit/components-react";
2
2
  import { MantineProvider } from "@mantine/core";
3
3
  import "@mantine/core/styles.css";
4
- import { useRef, useState } from "react";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { RoomHandler } from "./RoomHandler";
6
6
  import styles from "./styles.module.css";
7
7
  import { buildWidgetTheme } from "./theme";
@@ -9,7 +9,6 @@ import type { WidgetConfig } from "./types";
9
9
  import { usePolymorphSession } from "./usePolymorphSession";
10
10
  import { WidgetPanel } from "./WidgetPanel";
11
11
 
12
- // Chat bubble icon for FAB
13
12
  function ChatIcon() {
14
13
  return (
15
14
  <svg
@@ -23,69 +22,106 @@ function ChatIcon() {
23
22
  strokeLinecap="round"
24
23
  strokeLinejoin="round"
25
24
  >
25
+ <title>Chat</title>
26
26
  <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
27
27
  </svg>
28
28
  );
29
29
  }
30
30
 
31
+ function FabCloseIcon() {
32
+ return (
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ width="24"
36
+ height="24"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ strokeWidth="2"
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ >
44
+ <title>Close</title>
45
+ <path d="M18 6 6 18" />
46
+ <path d="m6 6 12 12" />
47
+ </svg>
48
+ );
49
+ }
50
+
31
51
  export function PolymorphWidget(props: WidgetConfig) {
32
- const { position = "bottom-right", branding } = props;
33
52
  const [open, setOpen] = useState(false);
34
53
  const session = usePolymorphSession(props);
35
- const primaryColor = branding?.primaryColor || "#171717";
54
+ const rc = session.resolvedConfig;
55
+ const primaryColor = rc?.primaryColor || "#171717";
56
+ const position = rc?.position || "bottom-right";
57
+ const darkMode = rc?.darkMode ?? false;
36
58
  const theme = buildWidgetTheme(primaryColor);
59
+ const { hasUnread, clearUnread, setPanelOpen } = session;
37
60
  const rootRef = useRef<HTMLDivElement>(null);
38
61
 
62
+ // Disconnect only when the user leaves the page (not when closing the panel)
63
+ useEffect(() => {
64
+ const handleBeforeUnload = () => session.disconnect();
65
+ window.addEventListener("beforeunload", handleBeforeUnload);
66
+ return () => {
67
+ window.removeEventListener("beforeunload", handleBeforeUnload);
68
+ session.disconnect();
69
+ };
70
+ }, [session.disconnect]);
71
+
72
+ const audio = useMemo(
73
+ () =>
74
+ session.isVoiceEnabled
75
+ ? {
76
+ echoCancellation: true,
77
+ noiseSuppression: true,
78
+ autoGainControl: true,
79
+ }
80
+ : false,
81
+ [session.isVoiceEnabled],
82
+ );
83
+
39
84
  return (
40
85
  <div
41
86
  ref={rootRef}
42
87
  className={`polymorph-widget ${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}
43
- style={{ colorScheme: props.darkMode ? "dark" : "light" }}
88
+ style={{ colorScheme: darkMode ? "dark" : "light" }}
44
89
  >
45
90
  <MantineProvider
46
91
  theme={theme}
47
- forceColorScheme={props.darkMode ? "dark" : "light"}
92
+ forceColorScheme={darkMode ? "dark" : "light"}
48
93
  cssVariablesSelector=".polymorph-widget"
49
94
  getRootElement={() => rootRef.current ?? undefined}
50
95
  >
51
- {open && (
52
- <WidgetPanel
53
- config={props}
54
- session={session}
55
- onClose={() => {
56
- session.disconnect();
57
- setOpen(false);
58
- }}
59
- />
60
- )}
96
+ <WidgetPanel
97
+ session={session}
98
+ onClose={() => {
99
+ setOpen(false);
100
+ setPanelOpen(false);
101
+ }}
102
+ hidden={!open}
103
+ />
61
104
  <button
105
+ key={session.wiggleKey}
62
106
  type="button"
63
- className={styles.fab}
107
+ className={`${styles.fab} ${open ? styles.fabOpen : ""} ${hasUnread && !open ? styles.fabWiggle : ""}`}
64
108
  style={{ backgroundColor: primaryColor }}
65
109
  onClick={() => {
66
- if (open) {
67
- session.disconnect();
68
- }
69
- setOpen((prev) => !prev);
110
+ const next = !open;
111
+ setOpen(next);
112
+ setPanelOpen(next);
113
+ if (next) clearUnread();
70
114
  }}
71
115
  >
72
- <ChatIcon />
116
+ {hasUnread && !open && <span className={styles.notificationDot} />}
117
+ {open ? <FabCloseIcon /> : <ChatIcon />}
73
118
  </button>
74
- {/* LiveKit room lives outside WidgetPanel so it mounts before the panel renders */}
75
119
  {session.roomConnection && (
76
120
  <LiveKitRoom
77
121
  token={session.roomConnection.token}
78
122
  serverUrl={session.roomConnection.livekitUrl}
79
123
  connect={true}
80
- audio={
81
- session.isVoiceEnabled
82
- ? {
83
- echoCancellation: true,
84
- noiseSuppression: true,
85
- autoGainControl: true,
86
- }
87
- : false
88
- }
124
+ audio={audio}
89
125
  video={false}
90
126
  style={{ display: "none" }}
91
127
  onDisconnected={session.disconnect}
@@ -19,6 +19,8 @@ export function RoomHandler({
19
19
  role: "user" | "agent",
20
20
  text: string,
21
21
  source: "chat" | "voice",
22
+ senderName?: string,
23
+ senderType?: "human",
22
24
  ) => void;
23
25
  isVoiceEnabled: boolean;
24
26
  }) {
@@ -66,6 +68,8 @@ export function RoomHandler({
66
68
  const data = JSON.parse(new TextDecoder().decode(payload));
67
69
  if (data.type === "chat_response") {
68
70
  addMessage("agent", data.text, "chat");
71
+ } else if (data.sender_type === "observer" && data.text) {
72
+ addMessage("agent", data.text, "chat", data.sender_name, "human");
69
73
  }
70
74
  } catch {
71
75
  /* ignore malformed */
@@ -83,7 +87,23 @@ export function RoomHandler({
83
87
  const isAgent =
84
88
  participant?.kind === ParticipantKind.AGENT ||
85
89
  participant?.identity?.includes("agent");
86
- addMessage(isAgent ? "agent" : "user", text, "voice");
90
+ // Observer transcriptions come from non-agent remote participants
91
+ // whose identity differs from the local (widget) user
92
+ const isObserver =
93
+ !isAgent &&
94
+ participant &&
95
+ participant.identity !== room.localParticipant.identity;
96
+ if (isObserver) {
97
+ addMessage(
98
+ "agent",
99
+ text,
100
+ "voice",
101
+ participant.name || participant.identity,
102
+ "human",
103
+ );
104
+ } else {
105
+ addMessage(isAgent ? "agent" : "user", text, "voice");
106
+ }
87
107
  }
88
108
  };
89
109
 
@@ -95,5 +115,5 @@ export function RoomHandler({
95
115
  };
96
116
  }, [room, addMessage]);
97
117
 
98
- return <RoomAudioRenderer />;
118
+ return isVoiceEnabled ? <RoomAudioRenderer /> : null;
99
119
  }
@@ -13,6 +13,8 @@ function VolumeIcon() {
13
13
  strokeWidth="2"
14
14
  strokeLinecap="round"
15
15
  strokeLinejoin="round"
16
+ role="img"
17
+ aria-label="Volume on"
16
18
  >
17
19
  <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
18
20
  <path d="M16 9a5 5 0 0 1 0 6" />
@@ -21,6 +23,28 @@ function VolumeIcon() {
21
23
  );
22
24
  }
23
25
 
26
+ function ScreenShareIcon() {
27
+ return (
28
+ <svg
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ width="14"
31
+ height="14"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ strokeWidth="2"
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ role="img"
39
+ aria-label="Screen share"
40
+ >
41
+ <rect width="20" height="14" x="2" y="3" rx="2" />
42
+ <line x1="8" x2="16" y1="21" y2="21" />
43
+ <line x1="12" x2="12" y1="17" y2="21" />
44
+ </svg>
45
+ );
46
+ }
47
+
24
48
  function VolumeOffIcon() {
25
49
  return (
26
50
  <svg
@@ -33,6 +57,8 @@ function VolumeOffIcon() {
33
57
  strokeWidth="2"
34
58
  strokeLinecap="round"
35
59
  strokeLinejoin="round"
60
+ role="img"
61
+ aria-label="Volume off"
36
62
  >
37
63
  <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
38
64
  <line x1="22" x2="16" y1="9" y2="15" />
@@ -44,21 +70,27 @@ function VolumeOffIcon() {
44
70
  interface VoiceOverlayProps {
45
71
  isVoiceEnabled: boolean;
46
72
  isMicActive: boolean;
73
+ isScreenSharing?: boolean;
47
74
  status: SessionStatus;
48
75
  onToggleVoice: () => void;
76
+ onToggleScreenShare?: () => void;
77
+ showScreenShare?: boolean;
49
78
  }
50
79
 
51
80
  export function VoiceOverlay({
52
81
  isVoiceEnabled,
53
82
  isMicActive,
83
+ isScreenSharing,
54
84
  status,
55
85
  onToggleVoice,
86
+ onToggleScreenShare,
87
+ showScreenShare,
56
88
  }: VoiceOverlayProps) {
57
- if (status !== "connected") return null;
89
+ const isConnecting = status === "connecting";
58
90
 
59
91
  return (
60
92
  <div className={styles.voiceOverlay}>
61
- {isVoiceEnabled ? (
93
+ {isVoiceEnabled && status === "connected" ? (
62
94
  <>
63
95
  <span className={styles.voiceBars}>
64
96
  <span className={styles.voiceBar} />
@@ -70,16 +102,33 @@ export function VoiceOverlay({
70
102
  </span>
71
103
  </>
72
104
  ) : (
73
- <span className={styles.voiceLabel}>Chat only</span>
105
+ <span className={styles.voiceLabel}>
106
+ {isConnecting ? "Connecting..." : "Chat only"}
107
+ </span>
74
108
  )}
75
- <button
76
- type="button"
77
- className={`${styles.voiceToggle} ${isVoiceEnabled ? styles.voiceToggleActive : ""}`}
78
- onClick={onToggleVoice}
79
- title={isVoiceEnabled ? "Turn off voice" : "Turn on voice"}
80
- >
81
- {isVoiceEnabled ? <VolumeIcon /> : <VolumeOffIcon />}
82
- </button>
109
+ <div className={styles.toggleGroup}>
110
+ {showScreenShare && status === "connected" && (
111
+ <button
112
+ type="button"
113
+ className={`${styles.voiceToggle} ${isScreenSharing ? styles.screenShareToggleActive : ""}`}
114
+ onClick={onToggleScreenShare}
115
+ title={isScreenSharing ? "Stop sharing" : "Share screen"}
116
+ aria-pressed={isScreenSharing}
117
+ >
118
+ <ScreenShareIcon />
119
+ </button>
120
+ )}
121
+ <button
122
+ type="button"
123
+ className={`${styles.voiceToggle} ${isVoiceEnabled && status === "connected" ? styles.voiceToggleActive : ""}`}
124
+ onClick={onToggleVoice}
125
+ disabled={isConnecting}
126
+ title={isVoiceEnabled ? "Turn off voice" : "Turn on voice"}
127
+ aria-pressed={isVoiceEnabled}
128
+ >
129
+ {isVoiceEnabled ? <VolumeIcon /> : <VolumeOffIcon />}
130
+ </button>
131
+ </div>
83
132
  </div>
84
133
  );
85
134
  }