feat(settings): Introduce new configuration options and settings panel enhancements for user customization

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-02 21:44:54 -07:00
parent 9098da09b9
commit fce50d488b
2 changed files with 394 additions and 0 deletions

View file

@ -0,0 +1,336 @@
import { useCallback } from 'react';
import type { ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import { Tooltip } from '@lilith/ui-feedback';
import { AnimatePresence, motion } from '@lilith/ui-motion';
import type { VoiceSessionState } from '../voice/VoiceSession';
import type { Settings, SettingsActions } from './useSettings';
export interface SettingsPanelProps {
open: boolean;
onClose: () => void;
sessionId: string;
connectionState: VoiceSessionState | 'disconnected';
settings: Settings;
onSettings: SettingsActions;
}
const Overlay = styled(motion.div)`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 500;
display: flex;
align-items: flex-end;
`;
const Sheet = styled(motion.div)`
width: 100%;
max-width: 640px;
margin: 0 auto;
background: #0d0d1a;
border-top: 1px solid #1a1a2e;
border-radius: 20px 20px 0 0;
padding: 8px 0 max(24px, env(safe-area-inset-bottom));
max-height: 80vh;
overflow-y: auto;
`;
const Handle = styled.div`
width: 36px;
height: 4px;
border-radius: 2px;
background: #2d3748;
margin: 0 auto 20px;
`;
const SectionTitle = styled.h2`
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #4a5568;
padding: 0 20px 8px;
`;
const Row = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #1a1a2e;
&:last-child {
border-bottom: none;
}
`;
const RowLabel = styled.span`
font-size: 15px;
color: #e2e8f0;
`;
const RowValue = styled.span`
font-size: 13px;
color: #4a5568;
font-family: 'SF Mono', 'Fira Mono', monospace;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const CopyButton = styled.button`
background: none;
border: 1px solid #2d3748;
border-radius: 6px;
color: #a0aec0;
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
margin-left: 8px;
flex-shrink: 0;
&:active {
background: #1a1a2e;
}
`;
const CONNECTION_LABELS: Record<string, { label: string; color: string }> = {
connected: { label: 'Connected', color: '#68d391' },
disconnected: { label: 'Disconnected', color: '#4a5568' },
connecting: { label: 'Connecting…', color: '#f9d94e' },
error: { label: 'Error', color: '#f56565' },
};
const StatusDot = styled.span<{ $color: string }>`
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $color }) => $color};
margin-right: 6px;
`;
const StatusValue = styled.span<{ $color: string }>`
font-size: 13px;
color: ${({ $color }) => $color};
display: flex;
align-items: center;
`;
const Section = styled.div`
margin-bottom: 8px;
`;
// Toggle
const ToggleTrack = styled.button<{ $on: boolean }>`
width: 44px;
height: 26px;
border-radius: 13px;
border: none;
background: ${({ $on }) => ($on ? '#553c9a' : '#2d3748')};
position: relative;
cursor: pointer;
transition: background 200ms ease;
flex-shrink: 0;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px #553c9a;
}
`;
const ToggleThumb = styled.span<{ $on: boolean }>`
position: absolute;
top: 3px;
left: ${({ $on }) => ($on ? '21px' : '3px')};
width: 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
transition: left 200ms ease;
`;
// Volume slider
const VolumeRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
flex: 1;
max-width: 180px;
`;
const VolumeSlider = styled.input`
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
border-radius: 2px;
background: #2d3748;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #553c9a;
cursor: pointer;
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #553c9a;
cursor: pointer;
border: none;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`;
const VolumeLabel = styled.span`
font-size: 12px;
color: #4a5568;
min-width: 32px;
text-align: right;
`;
function Toggle({ on, onChange, label }: { on: boolean; onChange: (v: boolean) => void; label: string }): ReactElement {
return (
<ToggleTrack
$on={on}
onClick={() => onChange(!on)}
role="switch"
aria-checked={on}
aria-label={label}
>
<ToggleThumb $on={on} />
</ToggleTrack>
);
}
export function SettingsPanel({
open,
onClose,
sessionId,
connectionState,
settings,
onSettings,
}: SettingsPanelProps): ReactElement {
const handleCopySession = useCallback(() => {
void navigator.clipboard.writeText(sessionId);
}, [sessionId]);
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
}, [onClose]);
const conn = CONNECTION_LABELS[connectionState] ?? { label: 'Disconnected', color: '#4a5568' };
return (
<AnimatePresence>
{open && (
<Overlay
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleOverlayClick}
>
<Sheet
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
<Handle />
<Section>
<SectionTitle>Voice Output (TTS)</SectionTitle>
<Row>
<RowLabel>Enable TTS</RowLabel>
<Tooltip content="Play AI responses as speech" position="top">
<Toggle
on={settings.ttsEnabled}
onChange={onSettings.setTtsEnabled}
label="Enable text-to-speech"
/>
</Tooltip>
</Row>
<Row>
<RowLabel>Volume</RowLabel>
<VolumeRow>
<VolumeSlider
type="range"
min={0}
max={1}
step={0.05}
value={settings.ttsVolume}
disabled={!settings.ttsEnabled}
onChange={(e) => onSettings.setTtsVolume(Number(e.target.value))}
aria-label="TTS volume"
/>
<VolumeLabel>{Math.round(settings.ttsVolume * 100)}%</VolumeLabel>
</VolumeRow>
</Row>
</Section>
<Section>
<SectionTitle>Voice Input (STT)</SectionTitle>
<Row>
<RowLabel>Enable microphone</RowLabel>
<Tooltip content="Hold-to-speak voice input" position="top">
<Toggle
on={settings.sttEnabled}
onChange={onSettings.setSttEnabled}
label="Enable speech-to-text"
/>
</Tooltip>
</Row>
</Section>
<Section>
<SectionTitle>Connection</SectionTitle>
<Row>
<RowLabel>Voice WebSocket</RowLabel>
<StatusValue $color={conn.color}>
<StatusDot $color={conn.color} />
{conn.label}
</StatusValue>
</Row>
<Row>
<RowLabel>Session ID</RowLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<RowValue title={sessionId}>
{sessionId ? sessionId.slice(0, 12) + '…' : '—'}
</RowValue>
{sessionId && (
<Tooltip content="Copy session ID to clipboard" position="top">
<CopyButton onClick={handleCopySession}>Copy</CopyButton>
</Tooltip>
)}
</div>
</Row>
</Section>
<Section>
<SectionTitle>About</SectionTitle>
<Row>
<RowLabel>Companion</RowLabel>
<RowValue>v1.0</RowValue>
</Row>
</Section>
</Sheet>
</Overlay>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,58 @@
import { useState, useCallback } from 'react';
export interface Settings {
ttsEnabled: boolean;
ttsVolume: number;
sttEnabled: boolean;
}
export interface SettingsActions {
setTtsEnabled: (enabled: boolean) => void;
setTtsVolume: (volume: number) => void;
setSttEnabled: (enabled: boolean) => void;
}
const STORAGE_KEY = 'companion_settings';
const DEFAULTS: Settings = {
ttsEnabled: true,
ttsVolume: 1.0,
sttEnabled: true,
};
function load(): Settings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) };
} catch {
return DEFAULTS;
}
}
function save(settings: Settings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// storage unavailable — non-fatal
}
}
export function useSettings(): Settings & SettingsActions {
const [settings, setSettings] = useState<Settings>(load);
const update = useCallback((patch: Partial<Settings>) => {
setSettings((prev) => {
const next = { ...prev, ...patch };
save(next);
return next;
});
}, []);
return {
...settings,
setTtsEnabled: useCallback((enabled: boolean) => update({ ttsEnabled: enabled }), [update]),
setTtsVolume: useCallback((volume: number) => update({ ttsVolume: volume }), [update]),
setSttEnabled: useCallback((enabled: boolean) => update({ sttEnabled: enabled }), [update]),
};
}