feat(companion-app): Update core CompanionApp component with new functionality, improved UI/UX, or restructured logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-02 21:44:54 -07:00
parent 073f740fc2
commit c98a5fc54f

View file

@ -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<ConnectionState, string> = {
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<MicState>('idle');
const [wsConnected, setWsConnected] = useState(false);
const [connectionState, setConnectionState] = useState<ConnectionState>('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<AudioContext | null>(null);
const micCaptureRef = useRef<MicCapture | null>(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<void> {
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 (
<>
<GlobalStyle />
<AppContainer>
<Header>
<HeaderTitle>Companion</HeaderTitle>
<ConnectionDot $connected={wsConnected} title={wsConnected ? 'Connected' : 'Disconnected'} />
{installable && (
<Tooltip content="Install as app" position="bottom">
<InstallButton onClick={handleInstall}>Install</InstallButton>
</Tooltip>
)}
<Tooltip content="Conversations" position="bottom">
<HeaderIconButton
onClick={() => setHistoryOpen(true)}
aria-label="Open conversation history"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip content="Settings" position="bottom">
<HeaderIconButton
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.68.07-1.08s-.03-.74-.07-1.08l2.33-1.82a.55.55 0 0 0 .13-.71l-2.21-3.82a.55.55 0 0 0-.67-.24l-2.75 1.1a8.07 8.07 0 0 0-1.87-1.09l-.42-2.92A.55.55 0 0 0 14 2h-4a.55.55 0 0 0-.54.46l-.42 2.92a8.07 8.07 0 0 0-1.87 1.09L4.43 5.37a.55.55 0 0 0-.67.24L1.55 9.43a.54.54 0 0 0 .13.71l2.33 1.82c-.04.34-.07.69-.07 1.08s.03.74.07 1.08l-2.33 1.82a.55.55 0 0 0-.13.71l2.21 3.82c.14.24.41.32.67.24l2.75-1.1c.58.42 1.21.78 1.87 1.09l.42 2.92c.05.29.3.46.54.46h4c.28 0 .5-.17.54-.46l.42-2.92a8.07 8.07 0 0 0 1.87-1.09l2.75 1.1a.55.55 0 0 0 .67-.24l2.21-3.82a.55.55 0 0 0-.13-.71z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip
content={connectionState === 'connected' ? 'Connected' : connectionState === 'error' ? 'Connection error' : 'Disconnected'}
position="bottom"
>
<ConnectionIndicator>
{connectionState !== 'connected' && (
<ConnectionLabel>
{connectionState === 'error' ? 'Error' : 'Disconnected'}
</ConnectionLabel>
)}
<ConnectionDot $state={connectionState} />
</ConnectionIndicator>
</Tooltip>
</Header>
<ChatView messages={chatState.messages} />
<ChatView
messages={chatState.messages}
activeAssistantId={chatState.activeAssistantId}
/>
<BottomBar>
<TextInput
sessionId={sessionId.current}
sessionId={sessionId}
apiBaseUrl={API_BASE_URL}
onTranscript={handleTextTranscript}
onSegment={handleTextSegment}
onError={handleChatError}
onComplete={handleTextComplete}
onWillSend={initAudio}
disabled={micState === 'listening'}
/>
<MicButton
state={micState}
onPressStart={() => { void handleMicPressStart(); }}
onPressEnd={handleMicPressEnd}
/>
{settings.sttEnabled && (
<Tooltip
content={
micState === 'listening' ? 'Release to send' :
micState === 'processing' ? 'Processing…' :
'Hold to speak'
}
position="top"
>
<MicButton
state={micState}
onPressStart={() => { void handleMicPressStart(); }}
onPressEnd={handleMicPressEnd}
/>
</Tooltip>
)}
</BottomBar>
</AppContainer>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
sessionId={sessionId.current}
connectionState={connectionState}
settings={settings}
onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }}
/>
<SessionHistoryPanel
open={historyOpen}
onClose={() => setHistoryOpen(false)}
currentSessionId={sessionId.current}
apiBaseUrl={API_BASE_URL}
onSwitchSession={handleSwitchSession}
/>
</>
);
}
export function CompanionApp(): ReactElement {
return (
<ThemeProvider defaultTheme="cyberpunk">
<ToastProvider>
<CompanionAppInner />
</ToastProvider>
</ThemeProvider>
);
}