diff --git a/@applications/web/src/app/CompanionApp.tsx b/@applications/web/src/app/CompanionApp.tsx index 4dea80c..6d667ac 100644 --- a/@applications/web/src/app/CompanionApp.tsx +++ b/@applications/web/src/app/CompanionApp.tsx @@ -10,6 +10,13 @@ import { MicCapture } from '../features/voice/MicCapture'; import { PcmPlayer } from '../features/voice/PcmPlayer'; import { VoiceSession } from '../features/voice/VoiceSession'; import { chatReducer, initialChatState } from '../features/chat/types'; +import type { HistoryMessage } from '../features/chat/types'; +import { ToastProvider, useToast, Tooltip } from '@lilith/ui-feedback'; +import { ThemeProvider } from '@lilith/ui-theme'; +import { createSession } from '../features/errors/sessionRecovery'; +import { SettingsPanel } from '../features/settings/SettingsPanel'; +import { useSettings } from '../features/settings/useSettings'; +import { SessionHistoryPanel } from '../features/history/SessionHistoryPanel'; const GlobalStyle = createGlobalStyle` *, *::before, *::after { @@ -60,14 +67,69 @@ const HeaderTitle = styled.h1` flex: 1; `; -const ConnectionDot = styled.span<{ $connected: boolean }>` +type ConnectionState = 'connected' | 'disconnected' | 'error'; + +const CONNECTION_COLORS: Record = { + connected: '#68d391', + disconnected: '#4a5568', + error: '#f56565', +}; + +const ConnectionIndicator = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +`; + +const ConnectionDot = styled.span<{ $state: ConnectionState }>` width: 8px; height: 8px; border-radius: 50%; - background: ${({ $connected }) => ($connected ? '#68d391' : '#4a5568')}; + background: ${({ $state }) => CONNECTION_COLORS[$state]}; flex-shrink: 0; `; +const ConnectionLabel = styled.span` + font-size: 11px; + color: #4a5568; +`; + +const InstallButton = styled.button` + background: none; + border: 1px solid #2d3748; + border-radius: 6px; + color: #a0aec0; + font-size: 11px; + padding: 3px 8px; + cursor: pointer; + margin-right: 8px; + flex-shrink: 0; + + &:active { + background: #1a1a2e; + } +`; + +const HeaderIconButton = styled.button` + background: none; + border: none; + color: #4a5568; + cursor: pointer; + padding: 4px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 6px; + + &:active { + background: #1a1a2e; + color: #a0aec0; + } +`; + const BottomBar = styled.div` display: flex; align-items: flex-end; @@ -78,14 +140,25 @@ const BottomBar = styled.div` flex-shrink: 0; `; -const API_BASE_URL: string = - import.meta.env.VITE_API_URL ?? 'http://localhost:3850'; +interface BeforeInstallPromptEvent extends Event { + prompt(): void; +} -export function CompanionApp(): ReactElement { +const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? '/api'; +// Socket.IO base URL: in dev the Vite proxy handles /socket.io/* → API server, +// so '' (origin) is correct. In prod set VITE_SOCKET_URL to the API server origin. +const SOCKET_BASE_URL: string = import.meta.env.VITE_SOCKET_URL ?? ''; + +function CompanionAppInner(): ReactElement { const sessionId = useRef(sessionStorage.getItem('companion_session_id') ?? ''); const [chatState, dispatch] = useReducer(chatReducer, initialChatState); const [micState, setMicState] = useState('idle'); - const [wsConnected, setWsConnected] = useState(false); + const [connectionState, setConnectionState] = useState('disconnected'); + const [installable, setInstallable] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const { showToast } = useToast(); + const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings(); const audioContextRef = useRef(null); const micCaptureRef = useRef(null); @@ -100,13 +173,21 @@ export function CompanionApp(): ReactElement { const handler = (e: Event) => { e.preventDefault(); installPromptRef.current = e; + setInstallable(true); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); + const handleInstall = useCallback(() => { + if (!installPromptRef.current) return; + (installPromptRef.current as BeforeInstallPromptEvent).prompt(); + installPromptRef.current = null; + setInstallable(false); + }, []); + const connectVoiceSession = useCallback((sid: string) => { - const session = new VoiceSession(API_BASE_URL, sid, { + const session = new VoiceSession(API_BASE_URL, SOCKET_BASE_URL, sid, { onTranscript: (text) => { const id = crypto.randomUUID(); dispatch({ type: 'ADD_USER_MESSAGE', id, text }); @@ -141,6 +222,8 @@ export function CompanionApp(): ReactElement { if (activeAssistantIdRef.current) { dispatch({ type: 'CLEAR_SPEAKING', id: activeAssistantIdRef.current }); } + dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); + setMicState('idle'); }, onListening: () => { @@ -149,37 +232,58 @@ export function CompanionApp(): ReactElement { }, onStateChange: (state) => { - setWsConnected(state === 'connected'); + setConnectionState(state === 'connecting' ? 'disconnected' : state); if (state === 'error') { setMicState('idle'); } }, + + onError: (message) => { + showToast(message, 'error'); + }, }); voiceSessionRef.current = session; session.connect(); - }, []); + }, [showToast]); // Initialize session on mount, then connect VoiceSession useEffect(() => { let cancelled = false; async function init(): Promise { - const existing = sessionStorage.getItem('companion_session_id'); - if (existing) { - sessionId.current = existing; - connectVoiceSession(existing); - return; + try { + let sid = sessionStorage.getItem('companion_session_id'); + + if (sid) { + // Validate the session still exists — 404 means it was cleared (server restart, etc.) + const histRes = await fetch(`${API_BASE_URL}/session/${sid}/history`); + if (histRes.ok) { + const messages = (await histRes.json()) as HistoryMessage[]; + if (!cancelled && messages.length > 0) { + dispatch({ type: 'LOAD_HISTORY', messages }); + } + } else if (histRes.status === 404) { + // Session expired — discard and create fresh + sessionStorage.removeItem('companion_session_id'); + sid = null; + } + // Other non-404 errors (network issues, 5xx): keep the session ID and proceed + } + + if (!sid) { + const newSessionId = await createSession(API_BASE_URL); + if (cancelled) return; + sid = newSessionId; + } + + if (cancelled) return; + sessionId.current = sid; + connectVoiceSession(sid); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + showToast(`Could not connect: ${msg}`, 'error'); } - - const res = await fetch(`${API_BASE_URL}/session`, { method: 'POST' }); - if (!res.ok) throw new Error(`POST /session failed: ${res.status}`); - const { session_id } = (await res.json()) as { session_id: string }; - - if (cancelled) return; - sessionId.current = session_id; - sessionStorage.setItem('companion_session_id', session_id); - connectVoiceSession(session_id); } void init(); @@ -190,7 +294,13 @@ export function CompanionApp(): ReactElement { micCaptureRef.current?.dispose(); pcmPlayerRef.current?.dispose(); }; - }, [connectVoiceSession]); + }, [connectVoiceSession, showToast]); + + // Apply TTS volume whenever it changes (0 when disabled) + useEffect(() => { + const volume = settings.ttsEnabled ? settings.ttsVolume : 0; + pcmPlayerRef.current?.setVolume(volume); + }, [settings.ttsEnabled, settings.ttsVolume]); /** * Initialize AudioContext and audio pipeline on first user gesture. @@ -227,13 +337,23 @@ export function CompanionApp(): ReactElement { }, []); const handleMicPressStart = useCallback(async () => { - await initAudio(); + try { + await initAudio(); + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + showToast('Microphone access denied. Enable it in browser settings.', 'error'); + } else { + showToast('Could not start microphone.', 'error'); + } + setMicState('idle'); + return; + } activeAssistantIdRef.current = null; setMicState('listening'); micCaptureRef.current?.start(); - }, [initAudio]); + }, [initAudio, showToast]); const handleMicPressEnd = useCallback(() => { micCaptureRef.current?.stop(); @@ -261,32 +381,167 @@ export function CompanionApp(): ReactElement { }); }, []); + const handleTextComplete = useCallback(() => { + dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); + }, []); + + const handleSwitchSession = useCallback((targetSessionId: string) => { + // Disconnect current voice session + voiceSessionRef.current?.disconnect(); + activeAssistantIdRef.current = null; + + const switchTo = async (sid: string) => { + dispatch({ type: 'LOAD_HISTORY', messages: [] }); + sessionId.current = sid; + sessionStorage.setItem('companion_session_id', sid); + + try { + const res = await fetch(`${API_BASE_URL}/session/${sid}/history`); + if (res.ok) { + const messages = (await res.json()) as HistoryMessage[]; + if (messages.length > 0) dispatch({ type: 'LOAD_HISTORY', messages }); + } + } catch { + // non-fatal + } + + connectVoiceSession(sid); + }; + + if (targetSessionId === 'new') { + createSession(API_BASE_URL) + .then((newId) => switchTo(newId)) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : 'Unknown error'; + showToast(`Could not create session: ${msg}`, 'error'); + }); + } else { + void switchTo(targetSessionId); + } + }, [connectVoiceSession, showToast]); + + const handleChatError = useCallback((message: string) => { + // Remove the stuck typing indicator + if (activeAssistantIdRef.current) { + dispatch({ type: 'REMOVE_MESSAGE', id: activeAssistantIdRef.current }); + activeAssistantIdRef.current = null; + } + // Show inline error in chat + dispatch({ + type: 'ADD_SYSTEM_MESSAGE', + id: crypto.randomUUID(), + text: message, + error: true, + }); + }, []); + return ( <>
Companion - + {installable && ( + + Install + + )} + + setHistoryOpen(true)} + aria-label="Open conversation history" + > + + + + + + + setSettingsOpen(true)} + aria-label="Open settings" + > + + + + + + + + {connectionState !== 'connected' && ( + + {connectionState === 'error' ? 'Error' : 'Disconnected'} + + )} + + +
- + - { void handleMicPressStart(); }} - onPressEnd={handleMicPressEnd} - /> + {settings.sttEnabled && ( + + { void handleMicPressStart(); }} + onPressEnd={handleMicPressEnd} + /> + + )}
+ + setSettingsOpen(false)} + sessionId={sessionId.current} + connectionState={connectionState} + settings={settings} + onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }} + /> + + setHistoryOpen(false)} + currentSessionId={sessionId.current} + apiBaseUrl={API_BASE_URL} + onSwitchSession={handleSwitchSession} + /> ); } + +export function CompanionApp(): ReactElement { + return ( + + + + + + ); +}