Files
jpremill 1feea40684 feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer
- Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support
- Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline
- Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots)
- Migrate core application to Svelte 5 runes (, , ) with reactive stores
- Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
2026-09-29 23:35:05 -04:00

305 lines
13 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { SCENARIOS } from './constants';
import { Scenario, SessionMode, UserSettings } from './types';
import ScenarioCard from './components/ScenarioCard';
import ChatSession from './components/ChatSession';
import VoiceSession from './components/VoiceSession';
import ScenarioGenerator from './components/ScenarioGenerator';
import CBTReference from './components/CBTReference';
import SettingsModal from './components/SettingsModal';
import Tooltip from './components/Tooltip';
import { Brain, Info, ShieldCheck, Filter, Plus, BookOpen, Activity, Zap, Settings } from 'lucide-react';
import { Button } from './components/ui/button';
import { ReloadPrompt } from './components/ReloadPrompt';
import { VersionBadge } from './components/VersionBadge';
const DEFAULT_SETTINGS: UserSettings = {
voiceName: 'Zephyr',
vocalStyle: 'Helpful',
genderPreference: 'Neutral',
roleplayModel: 'google/gemini-3.8-flash',
supervisorModel: 'google/gemini-3.8-flash'
};
const CUSTOM_SCENARIOS_KEY = 'cognitive-clarity-custom-scenarios';
const SETTINGS_KEY = 'cognitive-clarity-settings';
const App: React.FC = () => {
const [activeScenario, setActiveScenario] = useState<Scenario | null>(null);
const [sessionMode, setSessionMode] = useState<SessionMode>('text');
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('All');
const [isPreparing, setIsPreparing] = useState(false);
// Custom Scenarios State with LocalStorage persistence
const [customScenarios, setCustomScenarios] = useState<Scenario[]>(() => {
try {
const saved = localStorage.getItem(CUSTOM_SCENARIOS_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error("Failed to load custom scenarios", e);
return [];
}
});
const [showGenerator, setShowGenerator] = useState(false);
const [showReference, setShowReference] = useState(false);
const [showSettings, setShowSettings] = useState(false);
// User Settings State
const [userSettings, setUserSettings] = useState<UserSettings>(() => {
const saved = localStorage.getItem(SETTINGS_KEY);
return saved ? JSON.parse(saved) : DEFAULT_SETTINGS;
});
// Save scenarios whenever they change
useEffect(() => {
localStorage.setItem(CUSTOM_SCENARIOS_KEY, JSON.stringify(customScenarios));
}, [customScenarios]);
// Save settings whenever they change
useEffect(() => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(userSettings));
}, [userSettings]);
const handleSelectScenario = (scenario: Scenario, mode: SessionMode) => {
setIsPreparing(true);
setTimeout(() => {
setActiveScenario(scenario);
setSessionMode(mode);
setIsPreparing(false);
}, 1400);
};
const handleExitSession = () => {
setActiveScenario(null);
};
const handleScenarioGenerated = (newScenario: Scenario) => {
setCustomScenarios(prev => [newScenario, ...prev]);
setShowGenerator(false);
};
const handleDeleteScenario = useCallback((id: string) => {
setCustomScenarios(prev => prev.filter(s => s.id !== id));
}, []);
const allScenarios = [...customScenarios, ...SCENARIOS];
const filteredScenarios = allScenarios.filter(scenario =>
selectedDifficulty === 'All' || scenario.difficulty === selectedDifficulty
);
const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced'];
if (isPreparing) {
return (
<div className="min-h-screen bg-slate-900 flex flex-col items-center justify-center p-6 text-white overflow-hidden relative">
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-teal-500 rounded-full blur-[120px] animate-pulse"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-500 rounded-full blur-[120px] animate-pulse" style={{ animationDelay: '1s' }}></div>
</div>
<div className="relative z-10 flex flex-col items-center max-w-md text-center">
<div className="w-24 h-24 bg-white/5 rounded-3xl border border-white/10 flex items-center justify-center mb-8 relative">
<Brain size={48} className="text-teal-400" />
<div className="absolute inset-0 border-2 border-teal-500/30 rounded-3xl animate-ping opacity-20"></div>
</div>
<h2 className="text-2xl font-bold mb-3 tracking-tight">Initializing Session</h2>
<div className="flex items-center gap-2 mb-8">
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="h-1.5 w-1.5 bg-teal-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest ml-2">Calibrating AI Parameters</p>
</div>
<div className="w-full bg-white/5 h-1.5 rounded-full overflow-hidden mb-4 border border-white/5">
<div className="h-full bg-teal-500 rounded-full w-0 animate-[loading_1.4s_ease-in-out_forwards]"></div>
</div>
<div className="space-y-2 opacity-60">
<p className="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Zap size={10} className="text-teal-400" />
Configuring {sessionMode === 'voice' ? 'Voice Link' : 'Neural Chat Bridge'}
</p>
<p className="text-[10px] uppercase font-black tracking-tighter flex items-center justify-center gap-2">
<Activity size={10} className="text-blue-400" />
Applying {userSettings.voiceName} Vocal Profile
</p>
</div>
</div>
<style>{`
@keyframes loading {
0% { width: 0%; }
20% { width: 15%; }
50% { width: 70%; }
100% { width: 100%; }
}
`}</style>
</div>
);
}
if (activeScenario) {
return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center pt-8 px-4 pb-8 animate-in fade-in duration-700">
{sessionMode === 'text' ? (
<ChatSession scenario={activeScenario} userSettings={userSettings} onExit={handleExitSession} />
) : (
<div className="w-full max-w-4xl h-[80vh]">
<VoiceSession
scenario={activeScenario}
onExit={handleExitSession}
voiceSettings={userSettings}
onUpdateSettings={setUserSettings}
/>
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-[#FDFDFF] text-slate-900 font-sans relative animate-in fade-in duration-500">
<ReloadPrompt />
{/* Modals */}
{showGenerator && <ScenarioGenerator userSettings={userSettings} onGenerate={handleScenarioGenerated} onCancel={() => setShowGenerator(false)} />}
{showReference && <CBTReference onClose={() => setShowReference(false)} />}
{showSettings && <SettingsModal settings={userSettings} onUpdate={setUserSettings} onClose={() => setShowSettings(false)} />}
{/* Hero Section */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div className="flex items-center gap-3">
<div className="bg-teal-600 p-2 rounded-lg text-white shadow-sm shadow-teal-200">
<Brain size={32} />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Cognitive Clarity</h1>
</div>
<div className="flex items-center gap-2">
<Tooltip content="Adjust voice settings for audio sessions.">
<Button
variant="outline"
size="icon"
className="rounded-xl text-slate-400 hover:text-slate-800"
onClick={() => setShowSettings(true)}
>
<Settings size={20} />
</Button>
</Tooltip>
<Tooltip content="Explore the glossary of CBT distortions, techniques, and concepts.">
<Button
variant="outline"
className="rounded-xl text-slate-600 hover:text-teal-700 hover:bg-teal-50"
onClick={() => setShowReference(true)}
>
<BookOpen size={18} className="mr-2" />
Reference Guide
</Button>
</Tooltip>
</div>
</div>
<p className="text-xl text-slate-600 max-w-2xl leading-relaxed font-light">
An adaptive clinical training environment designed to strengthen cognitive flexibility and navigate social friction.
</p>
<div className="flex flex-wrap gap-4 mt-8">
<Tooltip content="Our scenarios are built on the foundations of Cognitive Behavioral Therapy.">
<div className="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100 cursor-help">
<ShieldCheck className="text-teal-600" size={18} />
<span>Evidence-Based Framework</span>
</div>
</Tooltip>
<Tooltip content="The AI character responds dynamically to your level of cognitive restructuring.">
<div className="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-100 cursor-help">
<Info className="text-teal-600" size={18} />
<span>Vocal Partner: {userSettings.voiceName}</span>
</div>
</Tooltip>
</div>
</div>
</div>
{/* Main Content */}
<main className="max-w-5xl mx-auto px-6 py-12">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-10">
<h2 className="text-2xl font-medium text-slate-800">Training Scenarios</h2>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div className="flex items-center bg-white border border-slate-200 rounded-lg p-1 shadow-sm overflow-x-auto">
<div className="px-3 text-slate-400 flex items-center gap-2 text-sm font-medium border-r border-slate-100 mr-1">
<Filter size={14} />
<span className="hidden sm:inline">Difficulty</span>
</div>
<div className="flex gap-1">
{difficulties.map((diff) => (
<Button
key={diff}
variant={selectedDifficulty === diff ? "secondary" : "ghost"}
size="sm"
onClick={() => setSelectedDifficulty(diff)}
className={`h-7 px-3 text-xs ${selectedDifficulty === diff ? 'bg-teal-50 text-teal-700 font-semibold hover:bg-teal-100 hover:text-teal-800' : 'text-slate-500 hover:text-slate-700'}`}
>
{diff}
</Button>
))}
</div>
</div>
<Tooltip content="Generate a unique roleplay situation based on your specific needs using AI." position="bottom">
<div>
<Button
onClick={() => setShowGenerator(true)}
className="bg-teal-600 hover:bg-teal-700 text-white shadow-md shadow-teal-100 font-bold"
>
<Plus size={18} className="mr-2" />
Create Custom
</Button>
</div>
</Tooltip>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredScenarios.map(scenario => (
<ScenarioCard
key={scenario.id}
scenario={scenario}
onSelect={handleSelectScenario}
onDelete={scenario.id.startsWith('custom-') ? handleDeleteScenario : undefined}
/>
))}
<Button
variant="outline"
onClick={() => setShowGenerator(true)}
className="flex flex-col items-center justify-center gap-4 p-6 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 hover:border-teal-300 hover:text-teal-600 hover:bg-teal-50/50 transition-all h-full min-h-[250px] group whitespace-normal"
>
<div className="bg-slate-50 p-4 rounded-full group-hover:bg-white transition-colors"><Plus size={32} /></div>
<div className="text-center">
<span className="block font-bold text-sm uppercase tracking-widest mb-1">Custom Scenario</span>
<span className="text-xs font-light">Generate a specific roleplay session</span>
</div>
</Button>
</div>
</main>
<footer className="bg-white border-t border-slate-200 py-12">
<div className="max-w-5xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-8">
<div className="flex items-center gap-4 text-slate-400">
<div className="flex items-center gap-2">
<Brain size={20} />
<span className="text-sm font-bold uppercase tracking-widest text-slate-900">Cognitive Clarity</span>
</div>
<VersionBadge />
</div>
</div>
</footer>
</div>
);
};
export default App;