@peers-app/peers-ui 0.19.12 → 0.19.13

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.
Files changed (53) hide show
  1. package/README.md +7 -2
  2. package/dist/components/voice-playback.d.ts +15 -0
  3. package/dist/components/voice-playback.js +86 -0
  4. package/dist/components/voice-subscribe-events.d.ts +0 -4
  5. package/dist/index.d.ts +10 -2
  6. package/dist/index.js +23 -2
  7. package/dist/operator-console/operator-console-state.d.ts +49 -0
  8. package/dist/operator-console/operator-console-state.js +97 -0
  9. package/dist/operator-console/operator-console.d.ts +32 -0
  10. package/dist/operator-console/operator-console.js +88 -0
  11. package/dist/root-layout/global-overlays.d.ts +37 -0
  12. package/dist/root-layout/global-overlays.js +37 -0
  13. package/dist/root-layout/layout-picker.d.ts +9 -0
  14. package/dist/root-layout/layout-picker.js +22 -0
  15. package/dist/root-layout/layouts/full-screen-layout.d.ts +12 -0
  16. package/dist/root-layout/layouts/full-screen-layout.js +60 -0
  17. package/dist/root-layout/layouts/tabs-with-console-layout.d.ts +20 -0
  18. package/dist/root-layout/layouts/tabs-with-console-layout.js +158 -0
  19. package/dist/root-layout/root-layout-host.d.ts +19 -0
  20. package/dist/root-layout/root-layout-host.js +88 -0
  21. package/dist/root-layout/root-layout-registry.d.ts +15 -0
  22. package/dist/root-layout/root-layout-registry.js +50 -0
  23. package/dist/root-layout/root-layout-state.d.ts +23 -0
  24. package/dist/root-layout/root-layout-state.js +54 -0
  25. package/dist/root-layout/root-layout.d.ts +30 -0
  26. package/dist/root-layout/root-layout.js +12 -0
  27. package/dist/screens/network-viewer/connection-troubleshooter.js +7 -11
  28. package/dist/screens/settings/settings-page.js +2 -1
  29. package/dist/tabs-layout/tabs-layout.d.ts +48 -4
  30. package/dist/tabs-layout/tabs-layout.js +27 -53
  31. package/dist/tabs-layout/tabs-state.d.ts +10 -0
  32. package/dist/tabs-layout/tabs-state.js +33 -1
  33. package/package.json +3 -3
  34. package/src/components/voice-playback.tsx +97 -0
  35. package/src/components/voice-subscribe-events.ts +0 -4
  36. package/src/index.tsx +24 -2
  37. package/src/operator-console/operator-console-state.ts +119 -0
  38. package/src/operator-console/operator-console.tsx +233 -0
  39. package/src/root-layout/global-overlays.ts +50 -0
  40. package/src/root-layout/layout-picker.tsx +47 -0
  41. package/src/root-layout/layouts/full-screen-layout.tsx +144 -0
  42. package/src/root-layout/layouts/tabs-with-console-layout.tsx +260 -0
  43. package/src/root-layout/root-layout-host.tsx +113 -0
  44. package/src/root-layout/root-layout-registry.ts +51 -0
  45. package/src/root-layout/root-layout-state.ts +55 -0
  46. package/src/root-layout/root-layout.ts +32 -0
  47. package/src/screens/network-viewer/connection-troubleshooter.tsx +10 -14
  48. package/src/screens/settings/settings-page.tsx +7 -1
  49. package/src/tabs-layout/tabs-layout.tsx +28 -74
  50. package/src/tabs-layout/tabs-state.ts +31 -0
  51. package/dist/components/chat-overlay.d.ts +0 -14
  52. package/dist/components/chat-overlay.js +0 -477
  53. package/src/components/chat-overlay.tsx +0 -854
@@ -1,854 +0,0 @@
1
- /**
2
- * Chat Overlay Component
3
- *
4
- * Floating UI element combining voice input with chat functionality.
5
- * Shows minimized voice button that expands to full chat overlay.
6
- * Supports both voice and text input in the same thread.
7
- */
8
-
9
- import {
10
- deviceVar,
11
- groupUserVar,
12
- type IMessage,
13
- Messages,
14
- newid,
15
- type PersistentVar,
16
- rpcServerCalls,
17
- subscribe,
18
- userVar,
19
- } from "@peers-app/peers-sdk";
20
- import { sortBy } from "lodash";
21
- import type React from "react";
22
- import { useCallback, useEffect, useRef, useState } from "react";
23
- import { me } from "../globals";
24
- import { MessageCompose } from "./messages/message-compose";
25
- import { MessageDisplay } from "./messages/message-display";
26
- import type {
27
- ChatOpenWithMessagePayload,
28
- VoiceErrorPayload,
29
- VoicePlayAudioPayload,
30
- VoiceSpeakPayload,
31
- VoiceStatePayload,
32
- VoiceSubscribeEvent,
33
- VoiceThreadPayload,
34
- VoiceTranscriptionPayload,
35
- VoiceVolumePayload,
36
- } from "./voice-subscribe-events";
37
-
38
- type VoiceState = "disabled" | "idle" | "listening" | "recording" | "processing" | "speaking";
39
-
40
- interface ChatOverlayProps {
41
- /** Position of the overlay */
42
- position?: "bottom-right" | "bottom-left";
43
- }
44
-
45
- // Voice settings interface (must match voice-settings.tsx)
46
- interface VoiceSettingsData {
47
- enabled: boolean;
48
- voiceOutputEnabled?: boolean; // TTS responses. Defaults to true when voice input enabled.
49
- wakeWord: string;
50
- wakeWordSensitivity: number;
51
- speechSensitivity: number;
52
- sttProvider: string;
53
- ttsProvider: string;
54
- ttsVoice?: string;
55
- ttsSpeed: number;
56
- }
57
-
58
- const DEFAULT_VOICE_SETTINGS: VoiceSettingsData = {
59
- enabled: false,
60
- voiceOutputEnabled: true,
61
- wakeWord: "COMPUTER",
62
- wakeWordSensitivity: 0.5,
63
- speechSensitivity: 0.3,
64
- sttProvider: "auto",
65
- ttsProvider: "browser",
66
- ttsSpeed: 1.0,
67
- };
68
-
69
- // Persistent var for tracking the chat overlay thread (synced across devices)
70
- let chatOverlayThreadPvar: PersistentVar<string | null> | null = null;
71
-
72
- function getChatOverlayThreadPvar(): PersistentVar<string | null> {
73
- if (!chatOverlayThreadPvar) {
74
- chatOverlayThreadPvar = groupUserVar<string | null>("chatOverlayThreadId", {
75
- defaultValue: null,
76
- });
77
- }
78
- return chatOverlayThreadPvar;
79
- }
80
-
81
- const voiceHubActive = deviceVar<boolean>("voiceHub:active", { defaultValue: false });
82
-
83
- // Voice settings pvar (shared with voice-settings.tsx and voice-service.ts)
84
- const voiceSettingsPvar = userVar<VoiceSettingsData>("voiceSettings", {
85
- defaultValue: DEFAULT_VOICE_SETTINGS,
86
- });
87
-
88
- // Position pvar for overlay placement
89
- interface OverlayPosition {
90
- x: number; // distance from right edge
91
- y: number; // distance from bottom edge
92
- }
93
- const DEFAULT_POSITION: OverlayPosition = { x: 20, y: 20 };
94
- const overlayPositionPvar = deviceVar<OverlayPosition>("chatOverlayPosition", {
95
- defaultValue: DEFAULT_POSITION,
96
- });
97
-
98
- export const ChatOverlay: React.FC<ChatOverlayProps> = ({
99
- position: _position = "bottom-right",
100
- }) => {
101
- const [voiceState, setVoiceState] = useState<VoiceState>("disabled");
102
- const [_transcription, setTranscription] = useState<string>("");
103
- const [volumeLevel, setVolumeLevel] = useState<number>(0);
104
- const [error, setError] = useState<string | null>(null);
105
- const [isExpanded, setIsExpanded] = useState(false);
106
- const [showSettings, setShowSettings] = useState(false);
107
- const [threadId, setThreadId] = useState<string | null>(null);
108
- const [messages, setMessages] = useState<IMessage[]>([]);
109
- const [parentMessage, setParentMessage] = useState<IMessage | null>(null);
110
- const [voiceSettings, setVoiceSettings] = useState<VoiceSettingsData>(DEFAULT_VOICE_SETTINGS);
111
- const [overlayPosition, setOverlayPosition] = useState<OverlayPosition>(DEFAULT_POSITION);
112
- const [isDragging, setIsDragging] = useState(false);
113
- const [pendingInitialMessage, setPendingInitialMessage] = useState<string | undefined>(undefined);
114
-
115
- const messagesEndRef = useRef<HTMLDivElement>(null);
116
- const messagesContainerRef = useRef<HTMLDivElement>(null);
117
- const currentAudioRef = useRef<HTMLAudioElement | null>(null);
118
- const dragStartRef = useRef<{
119
- mouseX: number;
120
- mouseY: number;
121
- posX: number;
122
- posY: number;
123
- } | null>(null);
124
-
125
- const scrollToBottom = useCallback(() => {
126
- setTimeout(() => {
127
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
128
- }, 100);
129
- }, []);
130
-
131
- // Load thread from pvar on mount
132
- useEffect(() => {
133
- const pvar = getChatOverlayThreadPvar();
134
- pvar.loadingPromise.then(() => {
135
- const savedThreadId = pvar();
136
- if (savedThreadId) {
137
- setThreadId(savedThreadId);
138
- // Also sync to voice service
139
- rpcServerCalls.voiceSetThreadId?.(savedThreadId).catch(() => {});
140
- }
141
- });
142
-
143
- // Subscribe to pvar changes (from other devices)
144
- const sub = pvar.subscribe((newThreadId) => {
145
- if (newThreadId !== threadId) {
146
- setThreadId(newThreadId);
147
- rpcServerCalls.voiceSetThreadId?.(newThreadId).catch(() => {});
148
- }
149
- });
150
-
151
- return () => sub.dispose();
152
- }, [threadId]);
153
-
154
- // Load voice settings from pvar
155
- useEffect(() => {
156
- voiceSettingsPvar.loadingPromise.then(() => {
157
- const saved = voiceSettingsPvar();
158
- if (saved) {
159
- setVoiceSettings(saved);
160
- }
161
- });
162
-
163
- // Subscribe to settings changes
164
- const sub = voiceSettingsPvar.subscribe((newSettings) => {
165
- if (newSettings) {
166
- setVoiceSettings(newSettings);
167
- }
168
- });
169
-
170
- return () => sub.dispose();
171
- }, []);
172
-
173
- // Load overlay position from pvar
174
- useEffect(() => {
175
- overlayPositionPvar.loadingPromise.then(() => {
176
- const saved = overlayPositionPvar();
177
- if (saved) {
178
- setOverlayPosition(saved);
179
- }
180
- });
181
- }, []);
182
-
183
- // Drag handlers
184
- const handleDragStart = useCallback(
185
- (e: React.MouseEvent) => {
186
- // Only start drag on middle-click or when holding shift
187
- if (e.button === 1 || e.shiftKey) {
188
- e.preventDefault();
189
- setIsDragging(true);
190
- dragStartRef.current = {
191
- mouseX: e.clientX,
192
- mouseY: e.clientY,
193
- posX: overlayPosition.x,
194
- posY: overlayPosition.y,
195
- };
196
- }
197
- },
198
- [overlayPosition],
199
- );
200
-
201
- useEffect(() => {
202
- if (!isDragging) return;
203
-
204
- const handleMouseMove = (e: MouseEvent) => {
205
- if (!dragStartRef.current) return;
206
-
207
- const deltaX = dragStartRef.current.mouseX - e.clientX;
208
- const deltaY = dragStartRef.current.mouseY - e.clientY;
209
-
210
- const newX = Math.max(
211
- 10,
212
- Math.min(window.innerWidth - 80, dragStartRef.current.posX + deltaX),
213
- );
214
- const newY = Math.max(
215
- 10,
216
- Math.min(window.innerHeight - 80, dragStartRef.current.posY + deltaY),
217
- );
218
-
219
- setOverlayPosition({ x: newX, y: newY });
220
- };
221
-
222
- const handleMouseUp = () => {
223
- setIsDragging(false);
224
- dragStartRef.current = null;
225
- // Save position
226
- overlayPositionPvar(overlayPosition);
227
- };
228
-
229
- document.addEventListener("mousemove", handleMouseMove);
230
- document.addEventListener("mouseup", handleMouseUp);
231
-
232
- return () => {
233
- document.removeEventListener("mousemove", handleMouseMove);
234
- document.removeEventListener("mouseup", handleMouseUp);
235
- };
236
- }, [isDragging, overlayPosition]);
237
-
238
- // Scroll to bottom when expanded or messages change
239
- useEffect(() => {
240
- if (isExpanded && messagesContainerRef.current) {
241
- // Use setTimeout to ensure DOM has updated
242
- setTimeout(() => {
243
- if (messagesContainerRef.current) {
244
- messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
245
- }
246
- }, 50);
247
- }
248
- }, [isExpanded]);
249
-
250
- // Subscribe to voice events
251
- useEffect(() => {
252
- const subscriptions = [
253
- subscribe(
254
- "chat:openWithMessage",
255
- (event: VoiceSubscribeEvent<ChatOpenWithMessagePayload>) => {
256
- const { message } = event.data || {};
257
- if (message) {
258
- const text = typeof message === "string" ? message : message.message;
259
- setPendingInitialMessage(text);
260
- }
261
- setIsExpanded(true);
262
- setShowSettings(false);
263
- },
264
- ),
265
- subscribe("voice:stateChanged", (event: VoiceSubscribeEvent<VoiceStatePayload>) => {
266
- setVoiceState(event.data.state as VoiceState);
267
- if (event.data.state === "idle") {
268
- setTranscription("");
269
- setError(null);
270
- }
271
- if (event.data.state === "recording" && !voiceHubActive()) {
272
- setIsExpanded(true);
273
- }
274
- }),
275
- subscribe("voice:transcription", (event: VoiceSubscribeEvent<VoiceTranscriptionPayload>) => {
276
- setTranscription(event.data.text);
277
- }),
278
- subscribe("voice:volumeLevel", (event: VoiceSubscribeEvent<VoiceVolumePayload>) => {
279
- setVolumeLevel(event.data.level);
280
- }),
281
- subscribe("voice:error", (event: VoiceSubscribeEvent<VoiceErrorPayload>) => {
282
- setError(event.data.message);
283
- }),
284
- subscribe("voice:threadChanged", (event: VoiceSubscribeEvent<VoiceThreadPayload>) => {
285
- const newThreadId = event.data.threadId;
286
- setThreadId(newThreadId);
287
- // Sync to pvar
288
- const pvar = getChatOverlayThreadPvar();
289
- pvar(newThreadId);
290
- }),
291
- ];
292
-
293
- // Load initial state
294
- rpcServerCalls
295
- .voiceGetState()
296
- .then(({ state }) => {
297
- setVoiceState(state);
298
- })
299
- .catch(() => {});
300
-
301
- // Load initial thread from voice service
302
- rpcServerCalls
303
- .voiceGetThreadId?.()
304
- .then((id) => {
305
- if (id) {
306
- setThreadId(id);
307
- const pvar = getChatOverlayThreadPvar();
308
- pvar(id);
309
- }
310
- })
311
- .catch(() => {});
312
-
313
- return () => {
314
- for (const sub of subscriptions) {
315
- sub.unsubscribe();
316
- }
317
- };
318
- }, []);
319
-
320
- // Handle browser TTS events
321
- useEffect(() => {
322
- const speakHandler = subscribe(
323
- "voice:speakText",
324
- (event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
325
- const { text, voice, rate } = event.data;
326
- if ("speechSynthesis" in window) {
327
- const utterance = new SpeechSynthesisUtterance(text);
328
- if (voice) {
329
- const voices = speechSynthesis.getVoices();
330
- const selectedVoice = voices.find((v) => v.name === voice);
331
- if (selectedVoice) utterance.voice = selectedVoice;
332
- }
333
- if (rate) utterance.rate = rate;
334
- utterance.onend = () => {
335
- rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
336
- };
337
- utterance.onerror = () => {
338
- rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
339
- };
340
- speechSynthesis.speak(utterance);
341
- } else {
342
- rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
343
- }
344
- },
345
- );
346
-
347
- const stopHandler = subscribe("voice:stopSpeaking", () => {
348
- if ("speechSynthesis" in window) {
349
- speechSynthesis.cancel();
350
- }
351
- if (currentAudioRef.current) {
352
- currentAudioRef.current.pause();
353
- currentAudioRef.current = null;
354
- }
355
- });
356
-
357
- const playHandler = subscribe(
358
- "voice:playAudio",
359
- async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
360
- const { audioBase64, mimeType } = event.data;
361
- try {
362
- const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
363
- const blob = new Blob([audioData], { type: mimeType });
364
- const url = URL.createObjectURL(blob);
365
- const audio = new Audio(url);
366
- currentAudioRef.current = audio;
367
- audio.onended = () => {
368
- URL.revokeObjectURL(url);
369
- currentAudioRef.current = null;
370
- rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
371
- };
372
- await audio.play();
373
- } catch (e) {
374
- console.error("Failed to play audio:", e);
375
- currentAudioRef.current = null;
376
- rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
377
- }
378
- },
379
- );
380
-
381
- return () => {
382
- speakHandler.unsubscribe();
383
- stopHandler.unsubscribe();
384
- playHandler.unsubscribe();
385
- };
386
- }, []);
387
-
388
- // Load messages when threadId changes
389
- useEffect(() => {
390
- if (!threadId) {
391
- setMessages([]);
392
- setParentMessage(null);
393
- return;
394
- }
395
-
396
- const currentThreadId = threadId;
397
-
398
- async function loadMessages() {
399
- const parent = await Messages().get(currentThreadId);
400
- setParentMessage(parent || null);
401
-
402
- const msgs = await Messages().list(
403
- { messageParentId: currentThreadId },
404
- { sortBy: ["createdAt", "messageId"] },
405
- );
406
- setMessages(msgs);
407
- scrollToBottom();
408
- }
409
-
410
- void loadMessages();
411
-
412
- // Subscribe to message changes
413
- const sub = Messages().dataChanged.subscribe((evt) => {
414
- if (
415
- evt.dataObject.messageParentId === currentThreadId ||
416
- evt.dataObject.messageId === currentThreadId
417
- ) {
418
- void loadMessages();
419
- }
420
- });
421
-
422
- return () => {
423
- sub.unsubscribe();
424
- };
425
- }, [threadId, scrollToBottom]);
426
-
427
- const updateVoiceSetting = useCallback(
428
- <K extends keyof VoiceSettingsData>(key: K, value: VoiceSettingsData[K]) => {
429
- const newSettings = { ...voiceSettings, [key]: value };
430
- setVoiceSettings(newSettings);
431
- voiceSettingsPvar(newSettings);
432
- },
433
- [voiceSettings],
434
- );
435
-
436
- const handleVoiceClick = useCallback(async () => {
437
- try {
438
- if (voiceState === "disabled") {
439
- window.location.hash = "#settings?tab=voice";
440
- return;
441
- }
442
-
443
- if (voiceState === "recording") {
444
- await rpcServerCalls.voiceStopRecording();
445
- } else if (voiceState === "speaking") {
446
- await rpcServerCalls.voiceStopPlayback();
447
- } else if (voiceState === "idle" || voiceState === "listening") {
448
- await rpcServerCalls.voiceStartRecording();
449
- }
450
- } catch (e) {
451
- console.error("Voice action failed:", e);
452
- }
453
- }, [voiceState]);
454
-
455
- const startNewThread = useCallback(() => {
456
- setThreadId(null);
457
- setMessages([]);
458
- setParentMessage(null);
459
- // Clear in voice service and pvar
460
- rpcServerCalls.voiceSetThreadId?.(null).catch(() => {});
461
- getChatOverlayThreadPvar()(null);
462
- }, []);
463
-
464
- const handleMessageSubmit = useCallback(
465
- async (message: IMessage) => {
466
- // Notify voice service of text activity (resets inactivity timer)
467
- rpcServerCalls.voiceNotifyTextActivity?.().catch(() => {});
468
-
469
- // Clear any pending initial message that was pre-filled
470
- setPendingInitialMessage(undefined);
471
-
472
- // If no thread yet, this message becomes the thread root
473
- if (!threadId) {
474
- message.messageParentId = undefined;
475
- await Messages().save(message);
476
- setThreadId(message.messageId);
477
- // Sync to voice service and pvar
478
- rpcServerCalls.voiceSetThreadId?.(message.messageId).catch(() => {});
479
- getChatOverlayThreadPvar()(message.messageId);
480
- } else {
481
- message.messageParentId = threadId;
482
- await Messages().save(message);
483
- }
484
-
485
- // Add to local state immediately for responsiveness
486
- setMessages((prev) => sortBy([...prev, message], "createdAt"));
487
- scrollToBottom();
488
- },
489
- [threadId, scrollToBottom],
490
- );
491
-
492
- const getVoiceIcon = (): string => {
493
- switch (voiceState) {
494
- case "disabled":
495
- return "bi-mic-mute";
496
- case "idle":
497
- return "bi-mic";
498
- case "listening":
499
- return "bi-ear";
500
- case "recording":
501
- return "bi-mic-fill";
502
- case "processing":
503
- return "bi-hourglass-split";
504
- case "speaking":
505
- return "bi-volume-up-fill";
506
- default:
507
- return "bi-mic";
508
- }
509
- };
510
-
511
- const getStateText = (): string => {
512
- switch (voiceState) {
513
- case "disabled":
514
- return "Voice disabled";
515
- case "idle":
516
- return "Say wake word or click";
517
- case "listening":
518
- return "Listening...";
519
- case "recording":
520
- return "Recording...";
521
- case "processing":
522
- return "Processing...";
523
- case "speaking":
524
- return "Speaking... (click to stop)";
525
- default:
526
- return "";
527
- }
528
- };
529
-
530
- const getButtonClass = (): string => {
531
- const base = "btn rounded-circle shadow";
532
- switch (voiceState) {
533
- case "disabled":
534
- return `${base} btn-secondary`;
535
- case "idle":
536
- return `${base} btn-outline-primary`;
537
- case "listening":
538
- return `${base} btn-info`;
539
- case "recording":
540
- return `${base} btn-danger`;
541
- case "processing":
542
- return `${base} btn-warning`;
543
- case "speaking":
544
- return `${base} btn-success`;
545
- default:
546
- return `${base} btn-secondary`;
547
- }
548
- };
549
-
550
- const positionStyles: React.CSSProperties = {
551
- position: "fixed",
552
- zIndex: 1050,
553
- bottom: `${overlayPosition.y}px`,
554
- right: `${overlayPosition.x}px`,
555
- cursor: isDragging ? "grabbing" : undefined,
556
- };
557
-
558
- return (
559
- <div style={positionStyles}>
560
- {/* Expanded chat panel */}
561
- {isExpanded && (
562
- <div
563
- className="card shadow mb-2"
564
- style={{
565
- width: "400px",
566
- maxHeight: "70vh",
567
- backgroundColor: "var(--bs-body-bg)",
568
- display: "flex",
569
- flexDirection: "column",
570
- }}
571
- >
572
- {/* Header */}
573
- <div className="card-header py-2 px-3 d-flex justify-content-between align-items-center">
574
- <div className="d-flex align-items-center gap-2">
575
- <button
576
- className="btn btn-sm btn-outline-secondary"
577
- onClick={startNewThread}
578
- title="Start new thread"
579
- >
580
- <i className="bi bi-plus-lg"></i>
581
- </button>
582
- <button
583
- className={`btn btn-sm ${showSettings ? "btn-secondary" : "btn-outline-secondary"}`}
584
- onClick={() => setShowSettings(!showSettings)}
585
- title="Voice settings"
586
- >
587
- <i className="bi bi-gear"></i>
588
- </button>
589
- <small className="text-muted">
590
- {showSettings ? "Settings" : threadId ? "Thread" : "New conversation"}
591
- </small>
592
- </div>
593
- <button className="btn btn-sm btn-link p-0" onClick={() => setIsExpanded(false)}>
594
- <i className="bi bi-chevron-down"></i>
595
- </button>
596
- </div>
597
-
598
- {/* Settings panel */}
599
- {showSettings && (
600
- <div className="card-body p-3" style={{ maxHeight: "50vh", overflowY: "auto" }}>
601
- {/* Voice Input Enabled Toggle */}
602
- <div className="d-flex align-items-center justify-content-between mb-2">
603
- <label className="form-label mb-0">Voice Input</label>
604
- <div className="form-check form-switch mb-0">
605
- <input
606
- className="form-check-input"
607
- type="checkbox"
608
- id="voiceEnabledToggle"
609
- checked={voiceSettings.enabled}
610
- onChange={(e) => updateVoiceSetting("enabled", e.target.checked)}
611
- role="switch"
612
- />
613
- <label className="form-check-label" htmlFor="voiceEnabledToggle">
614
- {voiceSettings.enabled ? "On" : "Off"}
615
- </label>
616
- </div>
617
- </div>
618
-
619
- {/* Voice Output Enabled Toggle - only when input is enabled */}
620
- {voiceSettings.enabled && (
621
- <div className="d-flex align-items-center justify-content-between mb-3 ms-2">
622
- <label className="form-label mb-0 small text-muted">Voice Output</label>
623
- <div className="form-check form-switch mb-0">
624
- <input
625
- className="form-check-input"
626
- type="checkbox"
627
- id="voiceOutputEnabledToggle"
628
- checked={voiceSettings.voiceOutputEnabled !== false}
629
- onChange={(e) => updateVoiceSetting("voiceOutputEnabled", e.target.checked)}
630
- role="switch"
631
- />
632
- <label className="form-check-label" htmlFor="voiceOutputEnabledToggle">
633
- {voiceSettings.voiceOutputEnabled !== false ? "On" : "Off"}
634
- </label>
635
- </div>
636
- </div>
637
- )}
638
-
639
- {/* Wake Word Sensitivity */}
640
- <div className="mb-3">
641
- <label className="form-label small mb-1">
642
- Wake Word Sensitivity: {voiceSettings.wakeWordSensitivity.toFixed(2)}
643
- </label>
644
- <input
645
- type="range"
646
- className="form-range"
647
- min="0"
648
- max="1"
649
- step="0.05"
650
- value={voiceSettings.wakeWordSensitivity}
651
- onChange={(e) =>
652
- updateVoiceSetting("wakeWordSensitivity", parseFloat(e.target.value))
653
- }
654
- />
655
- <div className="d-flex justify-content-between">
656
- <small className="text-muted" style={{ fontSize: "0.7rem" }}>
657
- Less sensitive
658
- </small>
659
- <small className="text-muted" style={{ fontSize: "0.7rem" }}>
660
- More sensitive
661
- </small>
662
- </div>
663
- </div>
664
-
665
- {/* Speech Detection Sensitivity */}
666
- <div className="mb-2">
667
- <label className="form-label small mb-1">
668
- Speech Detection: {voiceSettings.speechSensitivity.toFixed(2)}
669
- </label>
670
- <input
671
- type="range"
672
- className="form-range"
673
- min="0"
674
- max="1"
675
- step="0.05"
676
- value={voiceSettings.speechSensitivity}
677
- onChange={(e) =>
678
- updateVoiceSetting("speechSensitivity", parseFloat(e.target.value))
679
- }
680
- />
681
- <div className="d-flex justify-content-between">
682
- <small className="text-muted" style={{ fontSize: "0.7rem" }}>
683
- Quiet (louder speech)
684
- </small>
685
- <small className="text-muted" style={{ fontSize: "0.7rem" }}>
686
- Noisy (softer speech)
687
- </small>
688
- </div>
689
- </div>
690
-
691
- <div className="text-center mt-3">
692
- <a href="#settings?tab=voice" className="small text-muted">
693
- More voice settings <i className="bi bi-arrow-right"></i>
694
- </a>
695
- </div>
696
- </div>
697
- )}
698
-
699
- {/* Messages area */}
700
- {!showSettings && (
701
- <div
702
- ref={messagesContainerRef}
703
- className="card-body p-2"
704
- style={{
705
- flex: 1,
706
- overflowY: "auto",
707
- maxHeight: "40vh",
708
- minHeight: "100px",
709
- }}
710
- >
711
- {/* Parent message if exists */}
712
- {parentMessage && (
713
- <>
714
- <MessageDisplay message={parentMessage} isThreadParent />
715
- <hr className="my-2" />
716
- </>
717
- )}
718
-
719
- {/* Thread messages */}
720
- {messages.map((message, index) => (
721
- <MessageDisplay
722
- key={message.messageId}
723
- message={message}
724
- messageAbove={messages[index - 1]}
725
- />
726
- ))}
727
-
728
- {/* Error display */}
729
- {error && (
730
- <div className="alert alert-danger py-1 px-2 small">
731
- <i className="bi bi-exclamation-triangle me-1"></i>
732
- {error}
733
- </div>
734
- )}
735
-
736
- <div ref={messagesEndRef} />
737
- </div>
738
- )}
739
-
740
- {/* Voice status bar - only show when not in settings */}
741
- {!showSettings && voiceState !== "disabled" && voiceState !== "idle" && (
742
- <div className="px-3 py-1 border-top">
743
- <div className="d-flex align-items-center gap-2">
744
- <small className="text-muted">{getStateText()}</small>
745
- {voiceState === "recording" && (
746
- <div className="progress flex-grow-1" style={{ height: "4px" }}>
747
- <div
748
- className="progress-bar bg-danger"
749
- style={{ width: `${Math.min(100, volumeLevel * 500)}%` }}
750
- />
751
- </div>
752
- )}
753
- </div>
754
- </div>
755
- )}
756
-
757
- {/* Compose area - only show when not in settings */}
758
- {!showSettings && (
759
- <div className="card-footer p-2">
760
- <MessageCompose
761
- channelId={me?.userId || "default"}
762
- threadId={threadId || newid()}
763
- onMessageSubmit={handleMessageSubmit}
764
- initialMessage={pendingInitialMessage}
765
- key={
766
- pendingInitialMessage ? `pre-${pendingInitialMessage.slice(0, 20)}` : "default"
767
- }
768
- />
769
- </div>
770
- )}
771
- </div>
772
- )}
773
-
774
- {/* Floating button row */}
775
- <div
776
- className="d-flex gap-2 justify-content-end"
777
- onMouseDown={handleDragStart}
778
- style={{ userSelect: isDragging ? "none" : undefined }}
779
- >
780
- {/* Voice button - only show when voice is enabled and chat is expanded */}
781
- {voiceSettings.enabled && isExpanded && (
782
- <button
783
- className={getButtonClass()}
784
- style={{
785
- width: "56px",
786
- height: "56px",
787
- fontSize: "24px",
788
- transition: "all 0.2s ease",
789
- position: "relative",
790
- }}
791
- onClick={handleVoiceClick}
792
- title={getStateText()}
793
- >
794
- <i className={`bi ${getVoiceIcon()}`}></i>
795
-
796
- {/* Pulsing animation for listening/recording */}
797
- {(voiceState === "listening" || voiceState === "recording") && (
798
- <span
799
- className="position-absolute"
800
- style={{
801
- top: 0,
802
- left: 0,
803
- right: 0,
804
- bottom: 0,
805
- borderRadius: "50%",
806
- border: "2px solid currentColor",
807
- animation: "pulse 1.5s ease-out infinite",
808
- }}
809
- />
810
- )}
811
-
812
- {/* Processing spinner */}
813
- {voiceState === "processing" && (
814
- <span
815
- className="spinner-border spinner-border-sm position-absolute"
816
- style={{ top: "4px", right: "4px" }}
817
- />
818
- )}
819
- </button>
820
- )}
821
-
822
- {/* Chat expand/collapse button - always visible, stays on right */}
823
- <button
824
- className={`btn rounded-circle shadow ${isExpanded ? "btn-primary" : "btn-light"}`}
825
- style={{
826
- width: "56px",
827
- height: "56px",
828
- fontSize: "20px",
829
- }}
830
- onClick={() => setIsExpanded(!isExpanded)}
831
- title={isExpanded ? "Close chat (Shift+drag to move)" : "Open chat (Shift+drag to move)"}
832
- >
833
- <i className={`bi ${isExpanded ? "bi-chat-dots-fill" : "bi-chat-dots"}`}></i>
834
- </button>
835
- </div>
836
-
837
- {/* CSS for pulse animation */}
838
- <style>{`
839
- @keyframes pulse {
840
- 0% {
841
- transform: scale(1);
842
- opacity: 1;
843
- }
844
- 100% {
845
- transform: scale(1.5);
846
- opacity: 0;
847
- }
848
- }
849
- `}</style>
850
- </div>
851
- );
852
- };
853
-
854
- export default ChatOverlay;