From 5be6ab12f37080e0b55f34b9a59fa28e0e39d8dc Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Wed, 17 Dec 2025 18:50:23 +0800 Subject: [PATCH] add chat message overlay --- src/components/chat/ChatOverlay.tsx | 214 +++++++++++++++ src/components/playground/PhoneSimulator.tsx | 267 ++++++++++++------- src/components/playground/icons.tsx | 17 ++ 3 files changed, 398 insertions(+), 100 deletions(-) create mode 100644 src/components/chat/ChatOverlay.tsx diff --git a/src/components/chat/ChatOverlay.tsx b/src/components/chat/ChatOverlay.tsx new file mode 100644 index 0000000..fcf5cea --- /dev/null +++ b/src/components/chat/ChatOverlay.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { TranscriptionTile } from "@/transcriptions/TranscriptionTile"; +import { TrackReferenceOrPlaceholder } from "@livekit/components-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ChatOverlayProps { + agentAudioTrack?: TrackReferenceOrPlaceholder; + accentColor: string; + inputDisabled?: boolean; + isVisible: boolean; + position: { x: number; y: number }; + onPositionChange: (position: { x: number; y: number }) => void; + containerRef: React.RefObject; + onToggle: () => void; +} + +export function ChatOverlay({ + agentAudioTrack, + accentColor, + inputDisabled, + isVisible, + position, + onPositionChange, + containerRef, + onToggle, +}: ChatOverlayProps) { + const overlayRef = useRef(null); + const headerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + + // Responsive sizing based on container size + const [containerSize, setContainerSize] = useState({ width: 360, height: 500 }); + + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerSize({ width: rect.width, height: rect.height }); + } + }; + + updateSize(); + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef]); + + // Calculate overlay size as percentage of container, with min/max constraints + // Width: larger (up to 95% of container) + const overlayWidth = Math.min( + Math.max(containerSize.width * 0.9, 280), + containerSize.width * 0.95 + ); + // Height: smaller (reduced from 60-85% to 40-60%) + const overlayHeight = Math.min( + Math.max(containerSize.height * 0.4, 250), + containerSize.height * 0.6 + ); + + // Position overlay at center when first shown + const hasPositionedRef = useRef(false); + useEffect(() => { + if (isVisible && containerRef.current && containerSize.width > 0 && overlayWidth > 0 && overlayHeight > 0) { + // Calculate center position + const centerX = (containerSize.width - overlayWidth) / 2; + const centerY = (containerSize.height - overlayHeight) / 2; + + // Only auto-position on first show (when position is at origin) + if (!hasPositionedRef.current && position.x === 0 && position.y === 0) { + onPositionChange({ x: Math.max(0, centerX), y: Math.max(0, centerY) }); + hasPositionedRef.current = true; + } + } + }, [isVisible, containerSize.width, containerSize.height, overlayWidth, overlayHeight, containerRef, position, onPositionChange]); + + const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => { + if (!overlayRef.current || !headerRef.current) return; + + // Only allow dragging from the header, but not from buttons + const target = e.target as HTMLElement; + if (!headerRef.current.contains(target)) return; + + // Don't drag if clicking on the close button + if (target.closest('button') || target.closest('svg')) return; + + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + dragOffset.current = { + x: clientX - position.x, + y: clientY - position.y, + }; + }; + + const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => { + if (!isDragging || !containerRef.current || !overlayRef.current) return; + + e.preventDefault(); + e.stopPropagation(); + + const containerRect = containerRef.current.getBoundingClientRect(); + const overlayRect = overlayRef.current.getBoundingClientRect(); + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + let newX = clientX - dragOffset.current.x; + let newY = clientY - dragOffset.current.y; + + // Constrain within container bounds + const maxX = containerRect.width - overlayRect.width; + const maxY = containerRect.height - overlayRect.height; + const minY = 0; // Allow dragging to top + + newX = Math.max(0, Math.min(newX, maxX)); + newY = Math.max(minY, Math.min(newY, maxY)); + + onPositionChange({ x: newX, y: newY }); + }, [isDragging, containerRef, overlayRef, onPositionChange]); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + window.addEventListener("touchmove", handleDragMove, { passive: false }); + window.addEventListener("touchend", handleDragEnd); + } + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + window.removeEventListener("touchmove", handleDragMove); + window.removeEventListener("touchend", handleDragEnd); + }; + }, [isDragging, handleDragMove, handleDragEnd]); + + return ( +
+ {/* Header with drag handle and close button */} +
+
+
+ Chat +
+ +
+ + {/* Chat content with padding */} +
+ +
+
+ ); +} + diff --git a/src/components/playground/PhoneSimulator.tsx b/src/components/playground/PhoneSimulator.tsx index 4f63171..afbec4f 100644 --- a/src/components/playground/PhoneSimulator.tsx +++ b/src/components/playground/PhoneSimulator.tsx @@ -13,8 +13,9 @@ import { } from "@livekit/components-react"; import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client"; import { useEffect, useMemo, useState, useRef, useCallback } from "react"; -import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon } from "./icons"; +import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon, ChatIcon } from "./icons"; import { useToast } from "@/components/toast/ToasterProvider"; +import { ChatOverlay } from "@/components/chat/ChatOverlay"; export interface PhoneSimulatorProps { onConnect: () => void; @@ -67,6 +68,8 @@ export function PhoneSimulator({ const [interruptRejected, setInterruptRejected] = useState(false); const [isPushToTalkMode, setIsPushToTalkMode] = useState(true); // false = realtime mode, true = PTT mode (default) const pushToTalkButtonRef = useRef(null); + const [showChatOverlay, setShowChatOverlay] = useState(false); + const [chatOverlayPosition, setChatOverlayPosition] = useState({ x: 0, y: 0 }); // Will be positioned at top-right by ChatOverlay component useEffect(() => { const voiceAttr = config.settings.attributes?.find(a => a.key === "voice"); @@ -852,6 +855,28 @@ export function PhoneSimulator({ + {/* Chat Toggle Button - Top Right, aligned with audio visualizer */} + {roomState === ConnectionState.Connected && + voiceAssistant.agent && + phoneMode !== "important_message" && + phoneMode !== "capture" && ( + + )} + {/* Main Content */}
)} + {/* Chat Overlay - Hidden during capture and important_message modes */} + {roomState === ConnectionState.Connected && + voiceAssistant.agent && + phoneMode !== "capture" && + phoneMode !== "important_message" && ( + setShowChatOverlay(!showChatOverlay)} + /> + )} + {/* Call Controls Overlay */} {roomState === ConnectionState.Connected && ( phoneMode === "capture" ? ( @@ -1120,120 +1162,145 @@ export function PhoneSimulator({ {/* Push-to-Talk Mode Layout */} {isPushToTalkMode && phoneMode !== "hand_off" && voiceAssistant.agent && ( -
- {/* Mic Toggle and Camera Switch Buttons - Left (hidden in important_message mode) */} - {phoneMode !== "important_message" && ( -
- {/* Mic Toggle Button */} + <> + {/* Important Message Mode - Centered End Call Button */} + {phoneMode === "important_message" ? ( +
- {/* Camera Switch Button */} -
+
+ ) : ( +
+ {/* Left side: Mic Toggle and Camera Switch Buttons */} +
+ {/* Mic Toggle Button */} - {showCameraMenu && ( -
- {cameras.length === 0 ? ( -
- No cameras found -
- ) : ( - cameras.map((device) => ( - - )) - )} -
- )} + {/* Camera Switch Button */} +
+ + {showCameraMenu && ( +
+ {cameras.length === 0 ? ( +
+ No cameras found +
+ ) : ( + cameras.map((device) => ( + + )) + )} +
+ )} +
+ + {/* Center: Large Push-to-Talk Button */} + + + {/* Right side: End Call Button */} +
)} - - {/* Large Push-to-Talk Button - Center (hidden in important_message mode) */} - {phoneMode !== "important_message" && ( - - )} - - {/* End Call Button - Right (always shown in PTT mode) */} - -
+ )} {/* Realtime Mode Layout */} {!isPushToTalkMode && ( -
- {phoneMode !== "important_message" && phoneMode !== "hand_off" && ( - +
+ ) : ( +
+ {/* Left side: Mic Toggle */} + {phoneMode !== "hand_off" && ( + )} - - )} - {/* End Call Button */} - -
+ {/* Right side: End Call Button */} + +
+ )} + )} {/* Hand Off Mode - Show only End Call Button */} diff --git a/src/components/playground/icons.tsx b/src/components/playground/icons.tsx index 07e2a56..5a65602 100644 --- a/src/components/playground/icons.tsx +++ b/src/components/playground/icons.tsx @@ -207,3 +207,20 @@ export const VoiceIcon = ({ className }: { className?: string }) => ( ); + +export const ChatIcon = ({ className }: { className?: string }) => ( + + + +);