diff --git a/claudash/.env.example b/claudash/.env.example
index ba30936..621da7f 100644
--- a/claudash/.env.example
+++ b/claudash/.env.example
@@ -36,3 +36,6 @@ REACT_APP_DEBUG_MODE=false
# Optional: Date format for charts (MM/DD/YYYY, DD/MM/YYYY, MM/DD, DD/MM)
REACT_APP_CHART_DATE_FORMAT=MM/DD/YYYY
+
+# Optional: Default text-to-speech voice (must match exact voice name from browser)
+REACT_APP_TEXT_VOICE=Google UK English Female
diff --git a/claudash/src/components/ChatInterface.jsx b/claudash/src/components/ChatInterface.jsx
index ce5dc6f..932d931 100644
--- a/claudash/src/components/ChatInterface.jsx
+++ b/claudash/src/components/ChatInterface.jsx
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
-import { Box, TextField, IconButton, Paper } from '@mui/material';
+import { Box, TextField, IconButton, Paper, CircularProgress } from '@mui/material';
import { Send, ExpandLess, ExpandMore } from '@mui/icons-material';
import { sendChatMessage } from '../services/claudeService';
@@ -90,6 +90,23 @@ function ChatInterface() {
))}
+ {/* Loading indicator when Claude is thinking */}
+ {loading && (
+
+
+
+ Claude is thinking...
+
+
+ )}
)}
diff --git a/claudash/src/components/widgets/BriefingWidget.jsx b/claudash/src/components/widgets/BriefingWidget.jsx
index bd661db..a70cc50 100644
--- a/claudash/src/components/widgets/BriefingWidget.jsx
+++ b/claudash/src/components/widgets/BriefingWidget.jsx
@@ -1,12 +1,322 @@
-import { Skeleton, Box } from '@mui/material';
+import { useState, useEffect, useRef } from 'react';
+import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography } from '@mui/material';
+import { VolumeUp, PlayArrow, Pause, Settings, Stop } from '@mui/icons-material';
import GenericWidget from './GenericWidget';
function BriefingWidget({ briefing, sx }) {
+ const [showControls, setShowControls] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [autoPlay, setAutoPlay] = useState(false);
+ const [playbackSpeed, setPlaybackSpeed] = useState(1);
+ const [voices, setVoices] = useState([]);
+ const [selectedVoice, setSelectedVoice] = useState('');
+ const utteranceRef = useRef(null);
+
+ // Load available voices
+ useEffect(() => {
+ const loadVoices = () => {
+ const availableVoices = window.speechSynthesis.getVoices();
+ setVoices(availableVoices);
+
+ // Set default voice if not already set
+ if (!selectedVoice && availableVoices.length > 0) {
+ // First priority: Environment variable
+ const envVoice = import.meta.env.REACT_APP_TEXT_VOICE;
+ if (envVoice && availableVoices.find(v => v.name === envVoice)) {
+ setSelectedVoice(envVoice);
+ } else {
+ // Second priority: Saved voice in localStorage
+ const savedVoice = localStorage.getItem('ttsSelectedVoice');
+ if (savedVoice && availableVoices.find(v => v.name === savedVoice)) {
+ setSelectedVoice(savedVoice);
+ } else {
+ // Third priority: Try to find an English voice as default
+ const englishVoice = availableVoices.find(v => v.lang.startsWith('en'));
+ setSelectedVoice(englishVoice ? englishVoice.name : availableVoices[0].name);
+ }
+ }
+ }
+ };
+
+ loadVoices();
+ // Some browsers load voices asynchronously
+ window.speechSynthesis.addEventListener('voiceschanged', loadVoices);
+
+ return () => {
+ window.speechSynthesis.removeEventListener('voiceschanged', loadVoices);
+ };
+ }, [selectedVoice]);
+
+ // Load saved preferences
+ useEffect(() => {
+ const savedPrefs = localStorage.getItem('ttsPreferences');
+ if (savedPrefs) {
+ const prefs = JSON.parse(savedPrefs);
+ setAutoPlay(prefs.autoPlay || false);
+ setPlaybackSpeed(prefs.speed || 1);
+ }
+ }, []);
+
+ // Save preferences when they change
+ useEffect(() => {
+ localStorage.setItem('ttsPreferences', JSON.stringify({
+ autoPlay,
+ speed: playbackSpeed
+ }));
+ if (selectedVoice) {
+ localStorage.setItem('ttsSelectedVoice', selectedVoice);
+ }
+ }, [autoPlay, playbackSpeed, selectedVoice]);
+
+ // Auto-play on load if enabled and briefing is available
+ useEffect(() => {
+ if (autoPlay && briefing && !isPlaying) {
+ // Small delay to ensure the component is fully mounted
+ const timer = setTimeout(() => handlePlay(), 500);
+ return () => clearTimeout(timer);
+ }
+ }, [autoPlay, briefing]);
+
+ const handlePlay = () => {
+ if (!briefing) return;
+
+ // Stop any existing speech
+ window.speechSynthesis.cancel();
+
+ const utterance = new SpeechSynthesisUtterance(briefing);
+ utterance.rate = playbackSpeed;
+
+ // Set the selected voice
+ if (selectedVoice && voices.length > 0) {
+ const voice = voices.find(v => v.name === selectedVoice);
+ if (voice) {
+ utterance.voice = voice;
+ }
+ }
+
+ utterance.onstart = () => {
+ setIsPlaying(true);
+ setIsPaused(false);
+ };
+
+ utterance.onend = () => {
+ setIsPlaying(false);
+ setIsPaused(false);
+ utteranceRef.current = null;
+ };
+
+ utterance.onerror = (event) => {
+ console.error('Speech synthesis error:', event);
+ setIsPlaying(false);
+ setIsPaused(false);
+ utteranceRef.current = null;
+ };
+
+ utteranceRef.current = utterance;
+ window.speechSynthesis.speak(utterance);
+ };
+
+ const handlePause = () => {
+ if (window.speechSynthesis.speaking && !isPaused) {
+ window.speechSynthesis.pause();
+ setIsPaused(true);
+ }
+ };
+
+ const handleResume = () => {
+ if (isPaused) {
+ window.speechSynthesis.resume();
+ setIsPaused(false);
+ }
+ };
+
+ const handleStop = () => {
+ window.speechSynthesis.cancel();
+ setIsPlaying(false);
+ setIsPaused(false);
+ utteranceRef.current = null;
+ };
+
+ const handleSpeedChange = (event, newSpeed) => {
+ if (newSpeed !== null) {
+ setPlaybackSpeed(newSpeed);
+ // If currently playing, restart with new speed
+ if (isPlaying) {
+ handleStop();
+ setTimeout(() => handlePlay(), 100);
+ }
+ }
+ };
+
+ const toggleControls = () => {
+ setShowControls(!showControls);
+ };
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ window.speechSynthesis.cancel();
+ };
+ }, []);
+
return (
+ {!showControls && !isPlaying && (
+
+
+
+ )}
+ {isPlaying && !showControls && (
+
+ {isPaused ? : }
+
+ )}
+
+
+
+
+ )
+ }
>
+ {/* Audio Controls Panel */}
+ {briefing && (
+
+
+ {/* Play/Pause Button */}
+
+ {!isPlaying ? (
+
+
+
+ ) : (
+ <>
+
+ {isPaused ? : }
+
+
+
+
+ >
+ )}
+
+
+ {/* Voice Selection */}
+
+
+ Voice
+
+
+
+
+ {/* Speed Control */}
+
+ Speed:
+
+
+ 1x
+
+
+ 1.5x
+
+
+ 2x
+
+
+
+
+ {/* Auto-play Toggle */}
+ setAutoPlay(e.target.checked)}
+ size="small"
+ />
+ }
+ label="Auto-play"
+ sx={{ fontSize: '0.875rem' }}
+ />
+
+
+ )}
+
+ {/* Briefing Content */}
{briefing ? (
{briefing}
diff --git a/claudash/src/components/widgets/GenericWidget.jsx b/claudash/src/components/widgets/GenericWidget.jsx
index 389e7f4..215d28d 100644
--- a/claudash/src/components/widgets/GenericWidget.jsx
+++ b/claudash/src/components/widgets/GenericWidget.jsx
@@ -1,6 +1,6 @@
import { Paper, Box } from '@mui/material';
-function GenericWidget({ title, data, children, sx, ...props }) {
+function GenericWidget({ title, data, children, sx, headerAction, ...props }) {
return (
- {title}
+
+ {title}
+ {headerAction}
+
{children || {JSON.stringify(data)}
}