Boopster's picture
feat: Refine the onboarding flow with new steps and pause/skip logic, update headache logging to use a Monday-based week, and improve tool call error handling.
c386965
"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>
);
}