Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { useConversation } from "@/hooks/useConversation"; | |
| import { useListening } from "@/hooks/useListening"; | |
| import { useSession } from "@/hooks/useSession"; | |
| import { useConsent } from "@/hooks/useConsent"; | |
| import { SettingsPanel } from "@/components/SettingsPanel"; | |
| import { ReportsPanel } from "@/components/ReportsPanel"; | |
| import { ConsentModal } from "@/components/ConsentModal"; | |
| import { DashboardGrid } from "@/components/DashboardGrid"; | |
| import { ComponentOverlay } from "@/components/ComponentOverlay"; | |
| import { ObservabilityPanel } from "@/components/ObservabilityPanel"; | |
| import { Loader2, Mic, MicOff, Power, Square, Video, VideoOff } from "lucide-react"; | |
| interface ChatInterfaceProps { | |
| wsUrl?: string; | |
| } | |
| export function ChatInterface({ wsUrl = "ws://localhost:8000/api/stream/ws" }: ChatInterfaceProps) { | |
| const { messages, isConnected, latestCameraFrame, latestComponent, dismissLatestComponent, pendingTools } = useConversation({ | |
| wsUrl, | |
| }); | |
| const { isListening, toggle: toggleListening, setListening } = useListening(); | |
| const { isActive: isSessionActive, isToggling: isSessionToggling, toggle: baseToggleSession } = useSession(); | |
| const { hasConsented, isLoading: isConsentLoading, giveConsent } = useConsent(); | |
| // Camera view visibility state | |
| const [showCameraView, setShowCameraView] = useState(false); | |
| // Lifted panel state for voice control | |
| const [showSettings, setShowSettings] = useState(false); | |
| const [showReports, setShowReports] = useState(false); | |
| const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined); | |
| const toggleSettings = useCallback(() => { | |
| setShowSettings((prev) => { | |
| if (prev) setSettingsSection(undefined); // clear section when closing | |
| return !prev; | |
| }); | |
| }, []); | |
| const toggleReports = useCallback(() => setShowReports((prev) => !prev), []); | |
| // Observability panel state | |
| const [showObservability, setShowObservability] = useState(false); | |
| const toggleObservability = useCallback(() => setShowObservability((prev) => !prev), []); | |
| // Wrap session toggle to sync listening state | |
| const toggleSession = async () => { | |
| await baseToggleSession(); | |
| // If session is becoming active, the backend auto-enables listening | |
| if (!isSessionActive) { | |
| setListening(true); | |
| } | |
| }; | |
| // Listen for voice-triggered UI navigation events | |
| useEffect(() => { | |
| const handleNavigate = (e: Event) => { | |
| const { target, section } = (e as CustomEvent).detail; | |
| switch (target) { | |
| case "camera": | |
| setShowCameraView((prev) => !prev); | |
| break; | |
| case "settings": | |
| if (section) { | |
| // When a section is specified, always open settings to that tab | |
| setSettingsSection(section); | |
| setShowSettings(true); | |
| } else { | |
| setShowSettings((prev) => { | |
| if (prev) setSettingsSection(undefined); | |
| return !prev; | |
| }); | |
| } | |
| break; | |
| case "reports": | |
| setShowReports((prev) => !prev); | |
| break; | |
| case "observability": | |
| setShowObservability((prev) => !prev); | |
| break; | |
| } | |
| }; | |
| window.addEventListener("ui-navigate", handleNavigate); | |
| return () => window.removeEventListener("ui-navigate", handleNavigate); | |
| }, []); | |
| // Show loading state while checking consent | |
| if (isConsentLoading) { | |
| return ( | |
| <div className="flex items-center justify-center h-screen bg-surface text-primary"> | |
| <Loader2 className="w-8 h-8 animate-spin text-accent-cyan" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex flex-col h-screen text-primary font-sans"> | |
| {/* Consent Modal - shown if not consented */} | |
| {!hasConsented && <ConsentModal onConsent={giveConsent} />} | |
| {/* Header - Card style matching mockup */} | |
| <div className="px-6 pt-6" style={{ width: "100%", maxWidth: 1000, margin: "0 auto" }}> | |
| <header | |
| style={{ | |
| width: "100%", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| padding: "16px 24px", | |
| background: "rgba(30, 30, 30, 0.6)", | |
| backdropFilter: "blur(12px)", | |
| border: "1px solid var(--color-surface-overlay)", | |
| borderRadius: 16, | |
| }} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className="w-12 h-12 rounded-full overflow-hidden shadow-lg"> | |
| <img src="/reachy-mini-profile-pic.svg" alt="Reachy Mini" className="w-full h-full object-cover" style={{ imageRendering: '-webkit-optimize-contrast' }} /> | |
| </div> | |
| <div> | |
| <h1 className="font-heading" style={{ fontSize: 24 , fontWeight: 600, color: "var(--color-text-primary)" }}>Reachy Mini Minder</h1> | |
| <p style={{ fontSize: 16, color: "var(--color-text-secondary)", fontWeight: 300 }}>Always here for you</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <SessionToggle | |
| isActive={isSessionActive} | |
| isToggling={isSessionToggling} | |
| onToggle={toggleSession} | |
| /> | |
| {/* Only show listen toggle when session is active */} | |
| {isSessionActive && ( | |
| <ListenToggle | |
| isListening={isListening} | |
| onToggle={toggleListening} | |
| disabled={!isSessionActive} | |
| /> | |
| )} | |
| </div> | |
| </header> | |
| </div> | |
| {/* Dashboard Grid - Bento Layout */} | |
| {hasConsented && ( | |
| <DashboardGrid | |
| isSessionActive={isSessionActive} | |
| isConnected={isConnected} | |
| showCamera={showCameraView} | |
| cameraFrame={latestCameraFrame} | |
| /> | |
| )} | |
| {/* Component overlay - prominently displays the latest GenUI component */} | |
| {latestComponent && ( | |
| <ComponentOverlay component={latestComponent} onDismiss={dismissLatestComponent} /> | |
| )} | |
| {/* Disconnected Overlay */} | |
| {!isConnected && ( | |
| <div className="fixed inset-0 z-[100] bg-black flex items-center justify-center p-6 animate-in fade-in duration-300"> | |
| <div className="max-w-md w-full bg-surface-elevated border border-surface-overlay rounded-3xl overflow-hidden shadow-2xl text-center"> | |
| {/* Image banner - full width, no margin */} | |
| <div className="w-full h-40 md:h-52"> | |
| <img src="/no-wifi-cartoon.svg" alt="No connection" className="w-full h-full object-cover" /> | |
| </div> | |
| {/* Text content */} | |
| <div className="p-8 space-y-4"> | |
| <h2 className="text-xl font-bold">System Offline</h2> | |
| <p className="text-sm text-secondary"> | |
| Lost connection to the Reachy Mini Minder backend. Controls are disabled until the connection is restored. | |
| </p> | |
| <div className="pt-4 flex flex-col gap-2"> | |
| <div className="flex items-center justify-center gap-2 text-xs text-muted py-2 bg-surface-subtle rounded-lg"> | |
| <Loader2 className="w-3 h-3 animate-spin" /> | |
| Attempting to reconnect... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Sticky Bottom Navigation */} | |
| <nav className="sticky bottom-0 z-40 px-4 py-2 border-t border-surface-overlay bg-surface-elevated/95 backdrop-blur-md"> | |
| <div className="max-w-4xl mx-auto flex items-center justify-around"> | |
| {/* Camera toggle */} | |
| <button | |
| onClick={() => setShowCameraView(!showCameraView)} | |
| className={`flex flex-col items-center gap-1 p-2 rounded-xl transition-all ${showCameraView ? 'text-accent-cyan' : 'text-gray-400 hover:text-white'}`} | |
| aria-label={showCameraView ? 'Hide robot camera' : 'Show robot camera'} | |
| > | |
| {showCameraView ? <VideoOff className="w-5 h-5" /> : <Video className="w-5 h-5" />} | |
| <span className="text-[10px]">Camera</span> | |
| </button> | |
| {/* Reports */} | |
| <ReportsPanel isOpen={showReports} onToggle={toggleReports} /> | |
| {/* Settings */} | |
| <SettingsPanel isOpen={showSettings} onToggle={toggleSettings} activeSection={settingsSection} /> | |
| {/* Observability */} | |
| <ObservabilityPanel | |
| isOpen={showObservability} | |
| onToggle={toggleObservability} | |
| messages={messages} | |
| isSessionActive={isSessionActive} | |
| isConnected={isConnected} | |
| /> | |
| </div> | |
| </nav> | |
| </div> | |
| ); | |
| } | |
| interface ListenToggleProps { | |
| isListening: boolean; | |
| onToggle: () => void; | |
| disabled?: boolean; | |
| } | |
| function ListenToggle({ isListening, onToggle, disabled }: ListenToggleProps) { | |
| return ( | |
| <button | |
| onClick={onToggle} | |
| disabled={disabled} | |
| style={{ | |
| padding: "8px 16px", | |
| borderRadius: 999, | |
| fontSize: 13, | |
| fontWeight: 500, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 6, | |
| cursor: disabled ? "not-allowed" : "pointer", | |
| opacity: disabled ? 0.5 : 1, | |
| border: isListening | |
| ? "1px solid rgba(168, 218, 220, 0.3)" | |
| : "1px solid rgba(255, 193, 204, 0.3)", | |
| background: isListening | |
| ? "rgba(168, 218, 220, 0.15)" | |
| : "rgba(255, 193, 204, 0.15)", | |
| color: isListening | |
| ? "var(--color-accent-cyan)" | |
| : "var(--color-accent-pink)", | |
| }} | |
| title={disabled ? "Session is stopped" : isListening ? "Click to pause listening" : "Click to resume listening"} | |
| > | |
| {isListening ? ( | |
| <> | |
| <Mic style={{ width: 14, height: 14 }} /> | |
| <span style={{ display: "flex", alignItems: "center", gap: 4 }}> | |
| Listening | |
| <span style={{ display: "flex", gap: 2 }}> | |
| <span style={{ width: 4, height: 4, background: "currentColor", borderRadius: "50%", animation: "bounce 1s infinite", animationDelay: "0ms" }} /> | |
| <span style={{ width: 4, height: 4, background: "currentColor", borderRadius: "50%", animation: "bounce 1s infinite", animationDelay: "150ms" }} /> | |
| <span style={{ width: 4, height: 4, background: "currentColor", borderRadius: "50%", animation: "bounce 1s infinite", animationDelay: "300ms" }} /> | |
| </span> | |
| </span> | |
| </> | |
| ) : ( | |
| <> | |
| <MicOff style={{ width: 14, height: 14 }} /> | |
| Paused | |
| </> | |
| )} | |
| </button> | |
| ); | |
| } | |
| interface SessionToggleProps { | |
| isActive: boolean; | |
| isToggling: boolean; | |
| onToggle: () => void; | |
| } | |
| function SessionToggle({ isActive, isToggling, onToggle }: SessionToggleProps) { | |
| return ( | |
| <button | |
| onClick={onToggle} | |
| disabled={isToggling} | |
| style={{ | |
| padding: "8px 16px", | |
| borderRadius: 999, | |
| fontSize: 13, | |
| fontWeight: 500, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 6, | |
| cursor: "pointer", | |
| border: isActive | |
| ? "1px solid rgba(168, 218, 220, 0.3)" | |
| : "1px solid var(--color-surface-overlay)", | |
| background: isActive | |
| ? "rgba(168, 218, 220, 0.15)" | |
| : "var(--color-surface-elevated)", | |
| color: isActive | |
| ? "var(--color-accent-cyan)" | |
| : "var(--color-text-secondary)", | |
| }} | |
| title={isActive ? "Click to stop AI session" : "Click to start AI session"} | |
| > | |
| {isToggling ? ( | |
| <Loader2 className="w-3 h-3 animate-spin" /> | |
| ) : isActive ? ( | |
| <Power className="w-3 h-3" /> | |
| ) : ( | |
| <Square className="w-3 h-3" /> | |
| )} | |
| {isActive ? "End Session" : "Start Session"} | |
| </button> | |
| ); | |
| } | |