polymorph-sdk 0.2.3 → 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.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "src/index.ts",
@@ -61,15 +61,28 @@ export function ChatThread({
61
61
  }
62
62
 
63
63
  return (
64
- <div ref={scrollRef} className={styles.chatThread} onScroll={handleScroll}>
64
+ <div
65
+ ref={scrollRef}
66
+ className={styles.chatThread}
67
+ onScroll={handleScroll}
68
+ aria-live="polite"
69
+ aria-relevant="additions"
70
+ >
65
71
  {messages.map((msg) => (
66
72
  <div
67
73
  key={msg.id}
68
74
  className={`${styles.messageBubble} ${msg.role === "user" ? styles.userMessage : styles.agentMessage}`}
69
75
  style={
70
- msg.role === "user" ? { backgroundColor: primaryColor } : undefined
76
+ msg.role === "user"
77
+ ? { backgroundColor: primaryColor }
78
+ : msg.senderType === "human"
79
+ ? { backgroundColor: `${primaryColor}20` }
80
+ : undefined
71
81
  }
72
82
  >
83
+ {msg.senderName && (
84
+ <div className={styles.senderLabel}>{msg.senderName}</div>
85
+ )}
73
86
  {msg.text}
74
87
  {msg.source === "voice" && (
75
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";
@@ -49,37 +49,71 @@ function FabCloseIcon() {
49
49
  }
50
50
 
51
51
  export function PolymorphWidget(props: WidgetConfig) {
52
- const { position = "bottom-right", branding } = props;
53
52
  const [open, setOpen] = useState(false);
54
53
  const session = usePolymorphSession(props);
55
- 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;
56
58
  const theme = buildWidgetTheme(primaryColor);
59
+ const { hasUnread, clearUnread, setPanelOpen } = session;
57
60
  const rootRef = useRef<HTMLDivElement>(null);
58
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
+
59
84
  return (
60
85
  <div
61
86
  ref={rootRef}
62
87
  className={`polymorph-widget ${styles.widgetRoot} ${position === "bottom-left" ? styles.bottomLeft : styles.bottomRight}`}
63
- style={{ colorScheme: props.darkMode ? "dark" : "light" }}
88
+ style={{ colorScheme: darkMode ? "dark" : "light" }}
64
89
  >
65
90
  <MantineProvider
66
91
  theme={theme}
67
- forceColorScheme={props.darkMode ? "dark" : "light"}
92
+ forceColorScheme={darkMode ? "dark" : "light"}
68
93
  cssVariablesSelector=".polymorph-widget"
69
94
  getRootElement={() => rootRef.current ?? undefined}
70
95
  >
71
96
  <WidgetPanel
72
- config={props}
73
97
  session={session}
74
- onClose={() => setOpen(false)}
98
+ onClose={() => {
99
+ setOpen(false);
100
+ setPanelOpen(false);
101
+ }}
75
102
  hidden={!open}
76
103
  />
77
104
  <button
105
+ key={session.wiggleKey}
78
106
  type="button"
79
- className={`${styles.fab} ${open ? styles.fabOpen : ""}`}
107
+ className={`${styles.fab} ${open ? styles.fabOpen : ""} ${hasUnread && !open ? styles.fabWiggle : ""}`}
80
108
  style={{ backgroundColor: primaryColor }}
81
- onClick={() => setOpen((prev) => !prev)}
109
+ onClick={() => {
110
+ const next = !open;
111
+ setOpen(next);
112
+ setPanelOpen(next);
113
+ if (next) clearUnread();
114
+ }}
82
115
  >
116
+ {hasUnread && !open && <span className={styles.notificationDot} />}
83
117
  {open ? <FabCloseIcon /> : <ChatIcon />}
84
118
  </button>
85
119
  {session.roomConnection && (
@@ -87,15 +121,7 @@ export function PolymorphWidget(props: WidgetConfig) {
87
121
  token={session.roomConnection.token}
88
122
  serverUrl={session.roomConnection.livekitUrl}
89
123
  connect={true}
90
- audio={
91
- session.isVoiceEnabled
92
- ? {
93
- echoCancellation: true,
94
- noiseSuppression: true,
95
- autoGainControl: true,
96
- }
97
- : false
98
- }
124
+ audio={audio}
99
125
  video={false}
100
126
  style={{ display: "none" }}
101
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
  }
@@ -1,8 +1,15 @@
1
1
  import type { Room } from "livekit-client";
2
2
  import { useCallback, useRef, useState } from "react";
3
3
  import { ChatThread } from "./ChatThread";
4
+ import { IdentityForm } from "./IdentityForm";
4
5
  import styles from "./styles.module.css";
5
- import type { ChatMessage, SessionStatus, WidgetConfig } from "./types";
6
+ import type {
7
+ ChatMessage,
8
+ IdentityCollection,
9
+ ResolvedWidgetConfig,
10
+ SessionStatus,
11
+ WidgetUser,
12
+ } from "./types";
6
13
  import { VoiceOverlay } from "./VoiceOverlay";
7
14
 
8
15
  function MicIcon() {
@@ -88,42 +95,44 @@ function CloseIcon() {
88
95
  }
89
96
 
90
97
  interface WidgetPanelProps {
91
- config: WidgetConfig;
92
98
  session: {
93
99
  status: SessionStatus;
94
100
  roomConnection: { token: string; livekitUrl: string } | null;
95
101
  messages: ChatMessage[];
96
102
  isVoiceEnabled: boolean;
97
103
  isMicActive: boolean;
104
+ isScreenSharing: boolean;
105
+ hasObserver: boolean;
98
106
  error: string | null;
107
+ needsIdentityForm: boolean;
108
+ identityCollection: IdentityCollection | null;
109
+ resolvedConfig: ResolvedWidgetConfig | null;
99
110
  connect: () => Promise<void>;
100
111
  disconnect: () => void;
101
112
  addMessage: (
102
113
  role: "user" | "agent",
103
114
  text: string,
104
115
  source: "chat" | "voice",
116
+ senderName?: string,
117
+ senderType?: "human",
105
118
  ) => void;
106
119
  sendMessage: (text: string) => void;
107
120
  toggleMic: () => void;
108
121
  toggleVoice: () => void;
122
+ toggleScreenShare: () => void;
109
123
  setRoom: (room: Room | null) => void;
124
+ setUser: (user: WidgetUser) => void;
110
125
  };
111
126
  onClose: () => void;
112
127
  hidden?: boolean;
113
128
  }
114
129
 
115
- export function WidgetPanel({
116
- config,
117
- session,
118
- onClose,
119
- hidden,
120
- }: WidgetPanelProps) {
130
+ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
121
131
  const [inputValue, setInputValue] = useState("");
122
132
  const textareaRef = useRef<HTMLTextAreaElement>(null);
123
- const primaryColor = config.branding?.primaryColor || "#171717";
124
- const isChatOnlyAgent = (config.agentName || "")
125
- .toLowerCase()
126
- .includes("chat-agent");
133
+ const rc = session.resolvedConfig;
134
+ const primaryColor = rc?.primaryColor || "#171717";
135
+ const showVoice = rc?.enableVoice !== false;
127
136
 
128
137
  const handleSend = useCallback(() => {
129
138
  if (!inputValue.trim()) return;
@@ -158,9 +167,9 @@ export function WidgetPanel({
158
167
  <div className={styles.header}>
159
168
  <div>
160
169
  <div style={{ fontWeight: 500, fontSize: 16 }}>
161
- {config.branding?.title || "Hi there"}
170
+ {rc?.title || "Hi there"}
162
171
  </div>
163
- {config.branding?.subtitle && (
172
+ {rc?.subtitle && (
164
173
  <div
165
174
  style={{
166
175
  fontSize: 13,
@@ -168,7 +177,7 @@ export function WidgetPanel({
168
177
  marginTop: 2,
169
178
  }}
170
179
  >
171
- {config.branding.subtitle}
180
+ {rc.subtitle}
172
181
  </div>
173
182
  )}
174
183
  </div>
@@ -176,56 +185,73 @@ export function WidgetPanel({
176
185
  <CloseIcon />
177
186
  </button>
178
187
  </div>
179
- <ChatThread
180
- messages={session.messages}
181
- primaryColor={primaryColor}
182
- status={session.status}
183
- />
184
- {!isChatOnlyAgent && (
185
- <VoiceOverlay
186
- isVoiceEnabled={session.isVoiceEnabled}
187
- isMicActive={session.isMicActive}
188
- status={session.status}
189
- onToggleVoice={session.toggleVoice}
188
+ {session.needsIdentityForm && session.identityCollection ? (
189
+ <IdentityForm
190
+ collectEmail={session.identityCollection.collectEmail}
191
+ collectPhone={session.identityCollection.collectPhone}
192
+ primaryColor={primaryColor}
193
+ onSubmit={session.setUser}
190
194
  />
191
- )}
192
- {session.error && <div className={styles.errorText}>{session.error}</div>}
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
- />
209
- <button
210
- type="button"
211
- className={styles.iconButton}
212
- onClick={handleSend}
213
- disabled={!inputValue.trim() || session.status === "connecting"}
214
- >
215
- <SendIcon />
216
- </button>
217
- {session.isVoiceEnabled &&
218
- !isChatOnlyAgent &&
219
- session.status === "connected" && (
195
+ ) : (
196
+ <>
197
+ <ChatThread
198
+ messages={session.messages}
199
+ primaryColor={primaryColor}
200
+ status={session.status}
201
+ />
202
+ {(showVoice || session.hasObserver) && (
203
+ <VoiceOverlay
204
+ isVoiceEnabled={session.isVoiceEnabled}
205
+ isMicActive={session.isMicActive}
206
+ isScreenSharing={session.isScreenSharing}
207
+ status={session.status}
208
+ onToggleVoice={session.toggleVoice}
209
+ onToggleScreenShare={session.toggleScreenShare}
210
+ showScreenShare={session.hasObserver}
211
+ />
212
+ )}
213
+ {session.error && (
214
+ <div className={styles.errorText}>{session.error}</div>
215
+ )}
216
+ <div className={styles.inputBar}>
217
+ <textarea
218
+ ref={textareaRef}
219
+ className={styles.inputField}
220
+ placeholder={
221
+ session.status === "connecting"
222
+ ? "Connecting..."
223
+ : "Type a message..."
224
+ }
225
+ value={inputValue}
226
+ onChange={(e) => setInputValue(e.target.value)}
227
+ onInput={handleInput}
228
+ onKeyDown={handleKeyDown}
229
+ disabled={session.status === "connecting"}
230
+ rows={1}
231
+ />
220
232
  <button
221
233
  type="button"
222
- className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
223
- onClick={session.toggleMic}
234
+ className={styles.iconButton}
235
+ onClick={handleSend}
236
+ disabled={!inputValue.trim() || session.status === "connecting"}
224
237
  >
225
- {session.isMicActive ? <MicIcon /> : <MicOffIcon />}
238
+ <SendIcon />
226
239
  </button>
227
- )}
228
- </div>
240
+ {session.isVoiceEnabled &&
241
+ showVoice &&
242
+ session.status === "connected" && (
243
+ <button
244
+ type="button"
245
+ className={`${styles.iconButton} ${session.isMicActive ? styles.iconButtonActive : ""}`}
246
+ onClick={session.toggleMic}
247
+ title={session.isMicActive ? "Mute mic" : "Unmute mic"}
248
+ >
249
+ {session.isMicActive ? <MicIcon /> : <MicOffIcon />}
250
+ </button>
251
+ )}
252
+ </div>
253
+ </>
254
+ )}
229
255
  </div>
230
256
  );
231
257
  }