polymorph-sdk 0.2.2 → 0.2.3

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.
@@ -1,11 +1,10 @@
1
1
  import type { Room } from "livekit-client";
2
- import { useCallback, useState } from "react";
2
+ import { useCallback, useRef, useState } from "react";
3
3
  import { ChatThread } from "./ChatThread";
4
4
  import styles from "./styles.module.css";
5
5
  import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
6
6
  import { VoiceOverlay } from "./VoiceOverlay";
7
7
 
8
- // Internal SVG icons (no @tabler dependency)
9
8
  function MicIcon() {
10
9
  return (
11
10
  <svg
@@ -19,6 +18,7 @@ function MicIcon() {
19
18
  strokeLinecap="round"
20
19
  strokeLinejoin="round"
21
20
  >
21
+ <title>Microphone on</title>
22
22
  <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
23
23
  <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
24
24
  <line x1="12" x2="12" y1="19" y2="22" />
@@ -38,6 +38,7 @@ function MicOffIcon() {
38
38
  strokeLinecap="round"
39
39
  strokeLinejoin="round"
40
40
  >
41
+ <title>Microphone off</title>
41
42
  <line x1="2" x2="22" y1="2" y2="22" />
42
43
  <path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
43
44
  <path d="M5 10v2a7 7 0 0 0 12 5" />
@@ -60,6 +61,7 @@ function SendIcon() {
60
61
  strokeLinecap="round"
61
62
  strokeLinejoin="round"
62
63
  >
64
+ <title>Send</title>
63
65
  <path d="m22 2-7 20-4-9-9-4Z" />
64
66
  <path d="M22 2 11 13" />
65
67
  </svg>
@@ -78,6 +80,7 @@ function CloseIcon() {
78
80
  strokeLinecap="round"
79
81
  strokeLinejoin="round"
80
82
  >
83
+ <title>Close</title>
81
84
  <path d="M18 6 6 18" />
82
85
  <path d="m6 6 12 12" />
83
86
  </svg>
@@ -106,10 +109,17 @@ interface WidgetPanelProps {
106
109
  setRoom: (room: Room | null) => void;
107
110
  };
108
111
  onClose: () => void;
112
+ hidden?: boolean;
109
113
  }
110
114
 
111
- export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
115
+ export function WidgetPanel({
116
+ config,
117
+ session,
118
+ onClose,
119
+ hidden,
120
+ }: WidgetPanelProps) {
112
121
  const [inputValue, setInputValue] = useState("");
122
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
113
123
  const primaryColor = config.branding?.primaryColor || "#171717";
114
124
  const isChatOnlyAgent = (config.agentName || "")
115
125
  .toLowerCase()
@@ -119,6 +129,8 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
119
129
  if (!inputValue.trim()) return;
120
130
  session.sendMessage(inputValue);
121
131
  setInputValue("");
132
+ const el = textareaRef.current;
133
+ if (el) el.style.height = "auto";
122
134
  }, [inputValue, session]);
123
135
 
124
136
  const handleKeyDown = useCallback(
@@ -131,9 +143,18 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
131
143
  [handleSend],
132
144
  );
133
145
 
146
+ const handleInput = useCallback(() => {
147
+ const el = textareaRef.current;
148
+ if (!el) return;
149
+ el.style.height = "auto";
150
+ el.style.height = `${el.scrollHeight}px`;
151
+ }, []);
152
+
134
153
  return (
135
- <div className={styles.panel}>
136
- {/* Header */}
154
+ <div
155
+ className={`${styles.panel} ${hidden ? styles.panelHidden : ""}`}
156
+ aria-hidden={hidden}
157
+ >
137
158
  <div className={styles.header}>
138
159
  <div>
139
160
  <div style={{ fontWeight: 500, fontSize: 16 }}>
@@ -155,15 +176,11 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
155
176
  <CloseIcon />
156
177
  </button>
157
178
  </div>
158
-
159
- {/* Chat Thread */}
160
179
  <ChatThread
161
180
  messages={session.messages}
162
181
  primaryColor={primaryColor}
163
182
  status={session.status}
164
183
  />
165
-
166
- {/* Voice Overlay */}
167
184
  {!isChatOnlyAgent && (
168
185
  <VoiceOverlay
169
186
  isVoiceEnabled={session.isVoiceEnabled}
@@ -172,47 +189,34 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
172
189
  onToggleVoice={session.toggleVoice}
173
190
  />
174
191
  )}
175
-
176
- {/* Error */}
177
192
  {session.error && <div className={styles.errorText}>{session.error}</div>}
178
-
179
- {/* Connect button or Input bar */}
180
- {session.status === "idle" || session.status === "error" ? (
181
- <button
182
- type="button"
183
- className={styles.connectButton}
184
- style={{ backgroundColor: primaryColor }}
185
- onClick={session.connect}
186
- >
187
- Start conversation
188
- </button>
189
- ) : session.status === "connecting" ? (
193
+ <div className={styles.inputBar}>
194
+ <textarea
195
+ ref={textareaRef}
196
+ className={styles.inputField}
197
+ placeholder={
198
+ session.status === "connecting"
199
+ ? "Connecting..."
200
+ : "Type a message..."
201
+ }
202
+ value={inputValue}
203
+ onChange={(e) => setInputValue(e.target.value)}
204
+ onInput={handleInput}
205
+ onKeyDown={handleKeyDown}
206
+ disabled={session.status === "connecting"}
207
+ rows={1}
208
+ />
190
209
  <button
191
210
  type="button"
192
- className={styles.connectButton}
193
- style={{ backgroundColor: primaryColor }}
194
- disabled
211
+ className={styles.iconButton}
212
+ onClick={handleSend}
213
+ disabled={!inputValue.trim() || session.status === "connecting"}
195
214
  >
196
- Connecting...
215
+ <SendIcon />
197
216
  </button>
198
- ) : (
199
- <div className={styles.inputBar}>
200
- <input
201
- className={styles.inputField}
202
- placeholder="Type a message..."
203
- value={inputValue}
204
- onChange={(e) => setInputValue(e.target.value)}
205
- onKeyDown={handleKeyDown}
206
- />
207
- <button
208
- type="button"
209
- className={styles.iconButton}
210
- onClick={handleSend}
211
- disabled={!inputValue.trim()}
212
- >
213
- <SendIcon />
214
- </button>
215
- {session.isVoiceEnabled && !isChatOnlyAgent && (
217
+ {session.isVoiceEnabled &&
218
+ !isChatOnlyAgent &&
219
+ session.status === "connected" && (
216
220
  <button
217
221
  type="button"
218
222
  className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
@@ -221,8 +225,7 @@ export function WidgetPanel({ config, session, onClose }: WidgetPanelProps) {
221
225
  {session.isMicActive ? <MicIcon /> : <MicOffIcon />}
222
226
  </button>
223
227
  )}
224
- </div>
225
- )}
228
+ </div>
226
229
  </div>
227
230
  );
228
231
  }
@@ -1,304 +1,41 @@
1
- .widgetRoot {
2
- position: fixed;
3
- z-index: 9999;
4
- display: flex;
5
- flex-direction: column;
6
- align-items: flex-end;
7
- justify-content: flex-end;
8
- gap: 12px;
9
- pointer-events: none;
10
- }
11
-
12
- .bottomRight {
13
- top: 24px;
14
- bottom: 24px;
15
- right: 24px;
16
- }
17
-
18
- .bottomLeft {
19
- top: 24px;
20
- bottom: 24px;
21
- left: 24px;
22
- align-items: flex-start;
23
- }
24
-
25
- .fab {
26
- width: 56px;
27
- height: 56px;
28
- border-radius: 28px;
29
- border: none;
30
- cursor: pointer;
31
- display: flex;
32
- align-items: center;
33
- justify-content: center;
34
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
35
- transition:
36
- transform 150ms ease,
37
- box-shadow 150ms ease;
38
- color: white;
39
- pointer-events: auto;
40
- }
41
-
42
- .fab:hover {
43
- transform: scale(1.05);
44
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
45
- }
46
-
47
- .panel {
48
- width: 380px;
49
- max-height: 600px;
50
- display: flex;
51
- flex-direction: column;
52
- border-radius: 16px;
53
- box-shadow:
54
- 0 4px 24px rgba(0, 0, 0, 0.08),
55
- 0 12px 48px rgba(0, 0, 0, 0.12);
56
- overflow: hidden;
57
- background: var(--mantine-color-body);
58
- color: var(--mantine-color-text);
59
- pointer-events: auto;
60
- }
61
-
62
- .header {
63
- padding: 16px 16px 12px;
64
- border-bottom: 1px solid var(--mantine-color-default-border);
65
- display: flex;
66
- justify-content: space-between;
67
- align-items: flex-start;
68
- }
69
-
70
- .chatThread {
71
- flex: 1;
72
- overflow-y: auto;
73
- padding: 16px;
74
- display: flex;
75
- flex-direction: column;
76
- gap: 8px;
77
- min-height: 200px;
78
- max-height: 400px;
79
- }
80
-
81
- .messageBubble {
82
- max-width: 80%;
83
- padding: 8px 12px;
84
- border-radius: 12px;
85
- font-size: 14px;
86
- line-height: 1.4;
87
- word-wrap: break-word;
88
- }
89
-
90
- .agentMessage {
91
- align-self: flex-start;
92
- background: var(--mantine-color-gray-light);
93
- color: var(--mantine-color-text);
94
- }
95
-
96
- .userMessage {
97
- align-self: flex-end;
98
- color: white;
99
- }
100
-
101
- .voiceLabel {
102
- font-size: 10px;
103
- opacity: 0.6;
104
- margin-top: 2px;
105
- }
106
-
107
- .voiceOverlay {
108
- padding: 8px 16px;
109
- display: flex;
110
- align-items: center;
111
- gap: 8px;
112
- font-size: 13px;
113
- color: var(--mantine-color-dimmed);
114
- border-top: 1px solid var(--mantine-color-default-border);
115
- }
116
-
117
- .voiceBars {
118
- display: flex;
119
- align-items: center;
120
- gap: 2px;
121
- height: 16px;
122
- }
123
-
124
- .voiceBar {
125
- width: 3px;
126
- border-radius: 2px;
127
- background: #22c55e;
128
- animation: voiceBar 1.2s ease-in-out infinite;
129
- }
130
-
131
- .voiceBar:nth-child(1) {
132
- height: 6px;
133
- animation-delay: 0s;
134
- }
135
- .voiceBar:nth-child(2) {
136
- height: 12px;
137
- animation-delay: 0.2s;
138
- }
139
- .voiceBar:nth-child(3) {
140
- height: 8px;
141
- animation-delay: 0.4s;
142
- }
143
-
144
- @keyframes voiceBar {
145
- 0%,
146
- 100% {
147
- transform: scaleY(1);
148
- }
149
- 50% {
150
- transform: scaleY(0.4);
151
- }
152
- }
153
-
154
- .voiceToggle {
155
- margin-left: auto;
156
- width: 28px;
157
- height: 28px;
158
- border-radius: 14px;
159
- border: 1px solid var(--mantine-color-default-border);
160
- cursor: pointer;
161
- display: flex;
162
- align-items: center;
163
- justify-content: center;
164
- background: var(--mantine-color-gray-light);
165
- color: var(--mantine-color-dimmed);
166
- transition: all 150ms ease;
167
- }
168
-
169
- .voiceToggle:hover {
170
- background: var(--mantine-color-gray-light-hover);
171
- }
172
-
173
- .voiceToggleActive {
174
- background: light-dark(#dcfce7, #1a3a2a);
175
- border-color: light-dark(#bbf7d0, #2a5a3a);
176
- color: light-dark(#16a34a, #4ade80);
177
- }
178
-
179
- .voiceToggleActive:hover {
180
- background: light-dark(#bbf7d0, #2a5a3a);
181
- }
182
-
183
- .inputBar {
184
- padding: 12px 16px;
185
- border-top: 1px solid var(--mantine-color-default-border);
186
- display: flex;
187
- gap: 8px;
188
- align-items: center;
189
- }
190
-
191
- .inputField {
192
- flex: 1;
193
- border: 1px solid var(--mantine-color-default-border);
194
- border-radius: 8px;
195
- padding: 8px 12px;
196
- font-size: 14px;
197
- outline: none;
198
- font-family: inherit;
199
- background: var(--mantine-color-body);
200
- color: var(--mantine-color-text);
201
- }
202
-
203
- .inputField:focus {
204
- border-color: var(--mantine-color-dimmed);
205
- }
206
-
207
- .iconButton {
208
- width: 36px;
209
- height: 36px;
210
- border-radius: 8px;
211
- border: none;
212
- cursor: pointer;
213
- display: flex;
214
- align-items: center;
215
- justify-content: center;
216
- background: transparent;
217
- color: var(--mantine-color-dimmed);
218
- transition:
219
- background 150ms ease,
220
- color 150ms ease;
221
- }
222
-
223
- .iconButton:hover {
224
- background: var(--mantine-color-gray-light);
225
- }
226
-
227
- .iconButton:disabled {
228
- opacity: 0.4;
229
- cursor: default;
230
- }
231
-
232
- .iconButtonActive {
233
- color: #22c55e;
234
- }
235
-
236
- .connectButton {
237
- margin: 16px;
238
- padding: 10px 20px;
239
- border-radius: 8px;
240
- border: none;
241
- cursor: pointer;
242
- font-size: 14px;
243
- font-weight: 500;
244
- color: white;
245
- transition: opacity 150ms ease;
246
- }
247
-
248
- .connectButton:hover {
249
- opacity: 0.9;
250
- }
251
-
252
- .connectButton:disabled {
253
- opacity: 0.5;
254
- cursor: default;
255
- }
256
-
257
- .statusBadge {
258
- font-size: 11px;
259
- padding: 2px 8px;
260
- border-radius: 10px;
261
- font-weight: 500;
262
- }
263
-
264
- .errorText {
265
- color: #dc2626;
266
- font-size: 13px;
267
- padding: 0 16px;
268
- }
269
-
270
- .thinkingDots {
271
- display: flex;
272
- gap: 4px;
273
- align-items: center;
274
- height: 20px;
275
- }
276
-
277
- .thinkingDots span {
278
- width: 6px;
279
- height: 6px;
280
- border-radius: 50%;
281
- background: var(--mantine-color-dimmed);
282
- animation: thinkingDot 1.4s ease-in-out infinite;
283
- }
284
-
285
- .thinkingDots span:nth-child(2) {
286
- animation-delay: 0.2s;
287
- }
288
-
289
- .thinkingDots span:nth-child(3) {
290
- animation-delay: 0.4s;
291
- }
292
-
293
- @keyframes thinkingDot {
294
- 0%,
295
- 80%,
296
- 100% {
297
- opacity: 0.3;
298
- transform: scale(0.8);
299
- }
300
- 40% {
301
- opacity: 1;
302
- transform: scale(1);
303
- }
304
- }
1
+ .widgetRoot { position: fixed; z-index: 9999; display: flex; flex-direction: column; align-items: flex-end; justify-content: flex-end; gap: 12px; pointer-events: none; }
2
+ .bottomRight { top: 24px; bottom: 24px; right: 24px; }
3
+ .bottomLeft { top: 24px; bottom: 24px; left: 24px; align-items: flex-start; }
4
+ .fab { width: 56px; height: 56px; border-radius: 28px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transition: transform 150ms ease, box-shadow 150ms ease; color: white; pointer-events: auto; }
5
+ .fab svg { transition: transform 200ms ease; }
6
+ .fabOpen svg { transform: rotate(90deg); }
7
+ .fab:hover { transform: scale(1.05); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); }
8
+ .panel { width: 380px; max-height: 600px; flex-shrink: 1; min-height: 0; display: flex; flex-direction: column; border-radius: 16px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 12px 48px rgba(0, 0, 0, 0.12); overflow: hidden; background: var(--mantine-color-body); color: var(--mantine-color-text); pointer-events: auto; transition: opacity 200ms ease, transform 200ms ease; transform-origin: bottom right; }
9
+ .panelHidden { opacity: 0; transform: scale(0.95) translateY(8px); pointer-events: none; }
10
+ .header { padding: 16px 16px 12px; border-bottom: 1px solid var(--mantine-color-default-border); display: flex; justify-content: space-between; align-items: flex-start; }
11
+ .chatThread { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
12
+ .messageBubble { max-width: 80%; padding: 8px 12px; border-radius: 12px; font-size: 14px; line-height: 1.4; word-wrap: break-word; animation: messageAppear 200ms ease; }
13
+ @keyframes messageAppear { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
14
+ .agentMessage { align-self: flex-start; background: var(--mantine-color-gray-light); color: var(--mantine-color-text); }
15
+ .userMessage { align-self: flex-end; color: white; }
16
+ .voiceLabel { font-size: 10px; opacity: 0.6; margin-top: 2px; }
17
+ .voiceOverlay { padding: 8px 16px; display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--mantine-color-dimmed); border-top: 1px solid var(--mantine-color-default-border); }
18
+ .voiceBars { display: flex; align-items: center; gap: 2px; height: 16px; }
19
+ .voiceBar { width: 3px; border-radius: 2px; background: #22c55e; animation: voiceBar 1.2s ease-in-out infinite; }
20
+ .voiceBar:nth-child(1) { height: 6px; animation-delay: 0s; }
21
+ .voiceBar:nth-child(2) { height: 12px; animation-delay: 0.2s; }
22
+ .voiceBar:nth-child(3) { height: 8px; animation-delay: 0.4s; }
23
+ @keyframes voiceBar { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.4); } }
24
+ .voiceToggle { margin-left: auto; width: 28px; height: 28px; border-radius: 14px; border: 1px solid var(--mantine-color-default-border); cursor: pointer; display: flex; align-items: center; justify-content: center; background: var(--mantine-color-gray-light); color: var(--mantine-color-dimmed); transition: all 150ms ease; }
25
+ .voiceToggle:hover { background: var(--mantine-color-gray-light-hover); }
26
+ .voiceToggleActive { background: light-dark(#dcfce7, #1a3a2a); border-color: light-dark(#bbf7d0, #2a5a3a); color: light-dark(#16a34a, #4ade80); }
27
+ .voiceToggleActive:hover { background: light-dark(#bbf7d0, #2a5a3a); }
28
+ .inputBar { padding: 12px 16px; border-top: 1px solid var(--mantine-color-default-border); display: flex; gap: 8px; align-items: flex-end; }
29
+ .inputField { flex: 1; border: 1px solid var(--mantine-color-default-border); border-radius: 8px; padding: 8px 12px; font-size: 14px; line-height: 1.4; outline: none; font-family: inherit; background: var(--mantine-color-body); color: var(--mantine-color-text); resize: none; overflow-y: auto; max-height: 120px; }
30
+ .inputField:focus { border-color: var(--mantine-color-dimmed); }
31
+ .iconButton { width: 36px; height: 36px; border-radius: 8px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; background: transparent; color: var(--mantine-color-dimmed); transition: background 150ms ease, color 150ms ease; flex-shrink: 0; }
32
+ .iconButton:hover { background: var(--mantine-color-gray-light); }
33
+ .iconButton:disabled { opacity: 0.4; cursor: default; }
34
+ .iconButtonActive { color: #22c55e; }
35
+ .statusBadge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
36
+ .errorText { color: #dc2626; font-size: 13px; padding: 0 16px; }
37
+ .thinkingDots { display: flex; gap: 4px; align-items: center; height: 20px; }
38
+ .thinkingDots span { width: 6px; height: 6px; border-radius: 50%; background: var(--mantine-color-dimmed); animation: thinkingDot 1.4s ease-in-out infinite; }
39
+ .thinkingDots span:nth-child(2) { animation-delay: 0.2s; }
40
+ .thinkingDots span:nth-child(3) { animation-delay: 0.4s; }
41
+ @keyframes thinkingDot { 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
package/src/types.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export interface WidgetConfig {
2
2
  apiBaseUrl: string;
3
3
  apiKey?: string;
4
+ /** Server-side widget config ID. When set, metadata is ignored and config is resolved server-side. */
5
+ configId?: string;
4
6
  /** LiveKit dispatched agent name (default: "custom-voice-agent"). */
5
7
  agentName?: string;
6
8
  metadata?: Record<string, string | string[]>;
9
+ /** Pre-filled user identity. When provided, the agent skips asking for these fields. */
10
+ user?: WidgetUser;
7
11
  branding?: WidgetBranding;
8
12
  position?: "bottom-right" | "bottom-left";
9
13
  /** Enable voice call (default: true). When false, widget is chat-only. */
@@ -14,6 +18,15 @@ export interface WidgetConfig {
14
18
  darkMode?: boolean;
15
19
  }
16
20
 
21
+ export interface WidgetUser {
22
+ /** Display name (e.g. "Jane Smith") */
23
+ name?: string;
24
+ /** Email address */
25
+ email?: string;
26
+ /** Phone number */
27
+ phone?: string;
28
+ }
29
+
17
30
  export interface WidgetBranding {
18
31
  /** FAB and accent color (default: "#171717") */
19
32
  primaryColor?: string;
@@ -42,11 +42,25 @@ export function usePolymorphSession(config: WidgetConfig) {
42
42
  const [roomConnection, setRoomConnection] = useState<RoomConnection | null>(
43
43
  null,
44
44
  );
45
- const [messages, setMessages] = useState<ChatMessage[]>([]);
45
+ const greeting = config.branding?.greeting;
46
+ const [messages, setMessages] = useState<ChatMessage[]>(() =>
47
+ greeting
48
+ ? [
49
+ {
50
+ id: "greeting",
51
+ role: "agent" as const,
52
+ text: greeting,
53
+ source: "chat" as const,
54
+ timestamp: Date.now(),
55
+ },
56
+ ]
57
+ : [],
58
+ );
46
59
  const [isVoiceEnabled, setIsVoiceEnabled] = useState(defaultVoiceEnabled);
47
60
  const [isMicActive, setIsMicActive] = useState(defaultVoiceEnabled);
48
61
  const [error, setError] = useState<string | null>(null);
49
62
  const roomRef = useRef<Room | null>(null);
63
+ const pendingMessagesRef = useRef<string[]>([]);
50
64
 
51
65
  useEffect(() => {
52
66
  setIsVoiceEnabled(defaultVoiceEnabled);
@@ -109,12 +123,19 @@ export function usePolymorphSession(config: WidgetConfig) {
109
123
  },
110
124
  body: JSON.stringify({
111
125
  agent_name: config.agentName || "custom-voice-agent",
112
- metadata: {
113
- ...config.metadata,
114
- ...(config.branding?.greeting && {
115
- greeting: config.branding.greeting,
116
- }),
117
- },
126
+ config_id: config.configId ?? undefined,
127
+ metadata: config.configId
128
+ ? undefined
129
+ : {
130
+ ...config.metadata,
131
+ ...(config.branding?.greeting && {
132
+ greeting: config.branding.greeting,
133
+ }),
134
+ ...(config.user?.name && { user_name: config.user.name }),
135
+ ...(config.user?.email && { user_email: config.user.email }),
136
+ ...(config.user?.phone && { user_phone: config.user.phone }),
137
+ },
138
+ user_name: config.user?.name,
118
139
  external_user_id: externalUserId,
119
140
  }),
120
141
  ...restFetchOptions,
@@ -142,18 +163,25 @@ export function usePolymorphSession(config: WidgetConfig) {
142
163
 
143
164
  const sendMessage = useCallback(
144
165
  (text: string) => {
166
+ if (!text.trim()) return;
167
+ const trimmed = text.trim();
168
+ addMessage("user", trimmed, "chat");
169
+
145
170
  const room = roomRef.current;
146
- if (!room || !text.trim()) return;
147
- addMessage("user", text.trim(), "chat");
148
- const payload = new TextEncoder().encode(
149
- JSON.stringify({ text: text.trim() }),
150
- );
151
- room.localParticipant.publishData(payload, {
152
- reliable: true,
153
- topic: "chat_message",
154
- });
171
+ if (room) {
172
+ const payload = new TextEncoder().encode(
173
+ JSON.stringify({ text: trimmed }),
174
+ );
175
+ room.localParticipant.publishData(payload, {
176
+ reliable: true,
177
+ topic: "chat_message",
178
+ });
179
+ } else {
180
+ pendingMessagesRef.current.push(trimmed);
181
+ void connect();
182
+ }
155
183
  },
156
- [addMessage],
184
+ [addMessage, connect],
157
185
  );
158
186
 
159
187
  const toggleMic = useCallback(async () => {
@@ -182,7 +210,18 @@ export function usePolymorphSession(config: WidgetConfig) {
182
210
 
183
211
  const setRoom = useCallback((room: Room | null) => {
184
212
  roomRef.current = room;
185
- if (room) setStatus("connected");
213
+ if (room) {
214
+ setStatus("connected");
215
+ // Flush any messages queued before the room was ready
216
+ const pending = pendingMessagesRef.current.splice(0);
217
+ for (const text of pending) {
218
+ const payload = new TextEncoder().encode(JSON.stringify({ text }));
219
+ room.localParticipant.publishData(payload, {
220
+ reliable: true,
221
+ topic: "chat_message",
222
+ });
223
+ }
224
+ }
186
225
  }, []);
187
226
 
188
227
  return {