feat(web-settings): Add SettingsPage component and route configuration for user settings UI

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-12 00:13:38 -07:00
parent c906f12682
commit b63bf0f9e3
3 changed files with 520 additions and 21 deletions

View file

@ -31,28 +31,26 @@ const GlobalStyle = createGlobalStyle`
const NAVIGATION: NavSection[] = [
{
title: 'Core',
title: 'Companion',
items: [
{ to: '/', label: 'Chat' },
{ to: '/personas', label: 'Personas' },
{ to: '/personality', label: 'Personality' },
],
},
{
title: 'Automation',
items: [
{ to: '/nag', label: 'Nag' },
{ to: '/process', label: 'Process' },
{ to: '/sessions', label: 'Sessions' },
{ to: '/status', label: 'Status' },
],
},
{
title: 'Roadmap',
title: 'AI Core',
items: [
{ to: '/personas', label: 'Personas' },
{ to: '/personality', label: 'Personality' },
{ to: '/memory', label: 'Memory' },
{ to: '/skills', label: 'Skills' },
{ to: '/routines', label: 'Routines' },
{ to: '/nudges', label: 'Nudges' },
{ to: '/nag', label: 'Nag' },
{ to: '/process', label: 'Process' },
],
},
{
title: 'Settings',
items: [
{ to: '/settings', label: 'Settings' },
],
},

View file

@ -1,7 +1,6 @@
import { lazy, Suspense } from 'react';
import type { ReactElement } from 'react';
import { Routes, Route } from 'react-router-dom';
import { StubPage } from '../features/stubs/StubPage';
const ChatPage = lazy(() => import('../features/chat/ChatPage').then((m) => ({ default: m.ChatPage })));
const PersonasPage = lazy(() => import('../features/personas/PersonasPage').then((m) => ({ default: m.PersonasPage })));
@ -10,23 +9,24 @@ const PersonalityPage = lazy(() => import('../features/personality/PersonalityPa
const NagPage = lazy(() => import('../features/nag/NagPage').then((m) => ({ default: m.NagPage })));
const ProcessPage = lazy(() => import('../features/process/ProcessPage').then((m) => ({ default: m.ProcessPage })));
const StatusPage = lazy(() => import('../features/status/StatusPage').then((m) => ({ default: m.StatusPage })));
const MemoryPage = lazy(() => import('../features/memory/MemoryPage').then((m) => ({ default: m.MemoryPage })));
const SessionsPage = lazy(() => import('../features/sessions/SessionsPage').then((m) => ({ default: m.SessionsPage })));
const SettingsPage = lazy(() => import('../features/settings/SettingsPage').then((m) => ({ default: m.SettingsPage })));
export function AppRoutes(): ReactElement {
return (
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<ChatPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/personas" element={<PersonasPage />} />
<Route path="/personas/:id" element={<PersonaDetailPage />} />
<Route path="/personality" element={<PersonalityPage />} />
<Route path="/memory" element={<MemoryPage />} />
<Route path="/nag" element={<NagPage />} />
<Route path="/process" element={<ProcessPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/memory" element={<StubPage title="Memory" version="v1.1" description="Semantic memory storage, fact extraction, and contextual recall for persistent personality." />} />
<Route path="/skills" element={<StubPage title="Skills" version="future" description="Skill catalog for managing AI capabilities — enable, disable, and configure domain-specific tools." />} />
<Route path="/routines" element={<StubPage title="Routines" version="future" description="Scheduled routine management — morning check-ins, evening digests, and custom recurring flows." />} />
<Route path="/nudges" element={<StubPage title="Nudges" version="future" description="Gentle recurring pings — configurable speed, escalation, and context-aware prompting." />} />
<Route path="/settings" element={<StubPage title="Settings" version="future" description="Companion preferences, voice configuration, persona selection, and notification settings." />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
);

View file

@ -0,0 +1,501 @@
import { useCallback, useState } from 'react';
import type { ReactElement } from 'react';
import styled from '@lilith/ui-styled-components';
import type { ThemeInterface } from '@lilith/ui-theme';
import { useSettings } from './useSettings';
import { usePersonas } from '../personas/usePersonas';
import { usePushSubscription } from '../push/usePushSubscription';
import { createSession } from '../errors/sessionRecovery';
import { API_BASE_URL, AI_API_BASE_URL, SOCKET_BASE_URL } from '../../lib/constants';
interface ThemedProps {
theme: ThemeInterface;
}
const Page = styled.div`
max-width: 800px;
`;
const Title = styled.h1<ThemedProps>`
font-size: 24px;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
margin-bottom: ${({ theme }) => theme.spacing.lg};
`;
const Card = styled.div<ThemedProps>`
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 10px;
margin-bottom: ${({ theme }) => theme.spacing.lg};
overflow: hidden;
`;
const CardTitle = styled.h2<ThemedProps>`
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: ${({ theme }) => theme.colors.text.secondary};
padding: 12px 16px 8px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
`;
const Row = styled.div<ThemedProps>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
&:last-child {
border-bottom: none;
}
`;
const RowLabel = styled.span<ThemedProps>`
font-size: 14px;
color: ${({ theme }) => theme.colors.text.primary};
`;
const RowValue = styled.span<ThemedProps>`
font-size: 13px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: ${({ theme }) => theme.colors.text.secondary};
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ToggleTrack = styled.button<{ $on: boolean } & ThemedProps>`
width: 44px;
height: 26px;
border-radius: 13px;
border: none;
background: ${({ $on, theme }) => ($on ? theme.colors.primary : theme.colors.border)};
position: relative;
cursor: pointer;
transition: background 200ms ease;
flex-shrink: 0;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primary};
}
`;
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;
`;
const VolumeRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const VolumeSlider = styled.input<ThemedProps>`
-webkit-appearance: none;
appearance: none;
width: 120px;
height: 4px;
border-radius: 2px;
background: ${({ theme }) => theme.colors.border};
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: ${({ theme }) => theme.colors.primary};
cursor: pointer;
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: ${({ theme }) => theme.colors.primary};
cursor: pointer;
border: none;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`;
const VolumeLabel = styled.span<ThemedProps>`
font-size: 12px;
color: ${({ theme }) => theme.colors.text.secondary};
min-width: 36px;
text-align: right;
`;
const PersonaSelect = styled.select<ThemedProps>`
background: ${({ theme }) => theme.colors.background.primary};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 6px;
color: ${({ theme }) => theme.colors.text.primary};
font-size: 13px;
padding: 6px 10px;
cursor: pointer;
outline: none;
&:focus {
border-color: ${({ theme }) => theme.colors.primary};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const PersonaRow = styled.div<ThemedProps>`
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
flex-wrap: wrap;
&:last-child {
border-bottom: none;
}
`;
const ApplyButton = styled.button<ThemedProps>`
padding: 6px 14px;
border-radius: 6px;
border: 1px solid ${({ theme }) => theme.colors.primary};
background: transparent;
color: ${({ theme }) => theme.colors.primary};
font-size: 13px;
cursor: pointer;
transition: background 150ms ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: ${({ theme }) => theme.colors.primary}22;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ActiveBadge = styled.span<ThemedProps>`
font-size: 11px;
color: ${({ theme }) => theme.colors.success};
border: 1px solid ${({ theme }) => theme.colors.success};
border-radius: 4px;
padding: 2px 6px;
white-space: nowrap;
`;
const StatusBadge = styled.span<{ $ok: boolean } & ThemedProps>`
font-size: 12px;
color: ${({ $ok, theme }) => ($ok ? theme.colors.success : theme.colors.text.secondary)};
border: 1px solid ${({ $ok, theme }) => ($ok ? theme.colors.success : theme.colors.border)};
border-radius: 4px;
padding: 2px 8px;
`;
const PushButton = styled.button<{ $denied: boolean } & ThemedProps>`
padding: 6px 14px;
border-radius: 6px;
border: 1px solid ${({ $denied, theme }) => ($denied ? theme.colors.error : theme.colors.primary)};
background: transparent;
color: ${({ $denied, theme }) => ($denied ? theme.colors.error : theme.colors.primary)};
font-size: 13px;
cursor: ${({ $denied }) => ($denied ? 'default' : 'pointer')};
transition: background 150ms ease;
white-space: nowrap;
&:hover:not(:disabled) {
background: ${({ theme }) => theme.colors.primary}22;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const CopyButton = styled.button<ThemedProps>`
background: none;
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: 4px;
color: ${({ theme }) => theme.colors.text.secondary};
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
flex-shrink: 0;
margin-left: 8px;
&:active {
background: ${({ theme }) => theme.colors.background.primary};
}
`;
const ErrorText = styled.p<ThemedProps>`
font-size: 13px;
color: ${({ theme }) => theme.colors.error};
padding: 8px 16px;
`;
const LoadingText = styled.p<ThemedProps>`
font-size: 13px;
color: ${({ theme }) => theme.colors.text.secondary};
padding: 8px 16px;
`;
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>
);
}
function pushLabel(status: string): string {
switch (status) {
case 'subscribed': return 'Subscribed';
case 'requesting': return 'Requesting...';
case 'denied': return 'Blocked by browser';
case 'unsupported': return 'Not supported';
case 'error': return 'Retry';
default: return 'Subscribe';
}
}
export function SettingsPage(): ReactElement {
const {
ttsEnabled,
ttsVolume,
sttEnabled,
setTtsEnabled,
setTtsVolume,
setSttEnabled,
} = useSettings();
const { personas, loading: personasLoading, error: personasError } = usePersonas();
const { status: pushStatus, subscribe } = usePushSubscription(API_BASE_URL);
const activePersona = personas.find((p) => p.is_active);
const [selectedPersonaId, setSelectedPersonaId] = useState<string>('');
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false);
const currentSessionId = sessionStorage.getItem('companion_session_id') ?? '';
const handleApplyPersona = useCallback(async (): Promise<void> => {
const personaId = selectedPersonaId || activePersona?.id;
if (!personaId || applying) return;
setApplying(true);
setApplyError(null);
setApplySuccess(false);
try {
await createSession(API_BASE_URL, personaId);
setApplySuccess(true);
} catch (err) {
setApplyError(err instanceof Error ? err.message : 'Failed to apply persona');
} finally {
setApplying(false);
}
}, [selectedPersonaId, activePersona, applying]);
const handleCopySession = useCallback((): void => {
void navigator.clipboard.writeText(currentSessionId);
}, [currentSessionId]);
const pushActionable = pushStatus === 'idle' || pushStatus === 'error';
const pushSubscribed = pushStatus === 'subscribed';
const pushDenied = pushStatus === 'denied';
const pushDisabled = pushStatus === 'requesting' || pushStatus === 'unsupported' || pushSubscribed || pushDenied;
return (
<Page>
<Title>Settings</Title>
<Card>
<CardTitle>Voice Output (TTS)</CardTitle>
<Row>
<RowLabel>Enable TTS</RowLabel>
<Toggle on={ttsEnabled} onChange={setTtsEnabled} label="Enable text-to-speech" />
</Row>
<Row>
<RowLabel>Volume</RowLabel>
<VolumeRow>
<VolumeSlider
type="range"
min={0}
max={1}
step={0.05}
value={ttsVolume}
disabled={!ttsEnabled}
onChange={(e) => setTtsVolume(Number(e.target.value))}
aria-label="TTS volume"
/>
<VolumeLabel>{Math.round(ttsVolume * 100)}%</VolumeLabel>
</VolumeRow>
</Row>
</Card>
<Card>
<CardTitle>Voice Input (STT)</CardTitle>
<Row>
<RowLabel>Enable microphone</RowLabel>
<Toggle on={sttEnabled} onChange={setSttEnabled} label="Enable speech-to-text" />
</Row>
</Card>
<Card>
<CardTitle>Persona</CardTitle>
{personasLoading && <LoadingText>Loading personas...</LoadingText>}
{personasError && <ErrorText>{personasError}</ErrorText>}
{!personasLoading && !personasError && (
<>
<Row>
<RowLabel>Active</RowLabel>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RowValue>{activePersona?.name ?? '—'}</RowValue>
{activePersona && <ActiveBadge>active</ActiveBadge>}
</div>
</Row>
<PersonaRow>
<RowLabel>Switch to</RowLabel>
<PersonaSelect
value={selectedPersonaId}
onChange={(e) => {
setSelectedPersonaId(e.target.value);
setApplySuccess(false);
setApplyError(null);
}}
disabled={applying}
aria-label="Select persona"
>
<option value="">-- select persona --</option>
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.is_active ? ' (current)' : ''}
</option>
))}
</PersonaSelect>
<ApplyButton
onClick={() => { void handleApplyPersona(); }}
disabled={applying || !selectedPersonaId}
>
{applying ? 'Applying...' : 'Apply'}
</ApplyButton>
{applySuccess && <ActiveBadge>Session created</ActiveBadge>}
</PersonaRow>
{applyError && <ErrorText>{applyError}</ErrorText>}
</>
)}
</Card>
<Card>
<CardTitle>Push Notifications</CardTitle>
<Row>
<RowLabel>Status</RowLabel>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<StatusBadge $ok={pushSubscribed}>
{pushSubscribed
? 'Subscribed'
: pushDenied
? 'Blocked'
: pushStatus === 'unsupported'
? 'Unsupported'
: 'Not subscribed'}
</StatusBadge>
{!pushSubscribed && (
<PushButton
$denied={pushDenied}
onClick={pushActionable ? () => { void subscribe(); } : undefined}
disabled={pushDisabled}
aria-label={pushLabel(pushStatus)}
>
{pushLabel(pushStatus)}
</PushButton>
)}
</div>
</Row>
</Card>
<Card>
<CardTitle>Session</CardTitle>
<Row>
<RowLabel>Session ID</RowLabel>
<div style={{ display: 'flex', alignItems: 'center' }}>
<RowValue title={currentSessionId}>
{currentSessionId ? `${currentSessionId.slice(0, 16)}` : '—'}
</RowValue>
{currentSessionId && (
<CopyButton onClick={handleCopySession} aria-label="Copy session ID">
Copy
</CopyButton>
)}
</div>
</Row>
</Card>
<Card>
<CardTitle>Environment</CardTitle>
<Row>
<RowLabel>API Base URL</RowLabel>
<RowValue title={API_BASE_URL}>{API_BASE_URL}</RowValue>
</Row>
<Row>
<RowLabel>AI API Base URL</RowLabel>
<RowValue title={AI_API_BASE_URL}>{AI_API_BASE_URL}</RowValue>
</Row>
<Row>
<RowLabel>Socket Base URL</RowLabel>
<RowValue title={SOCKET_BASE_URL || '(origin)'}>{SOCKET_BASE_URL || '(origin)'}</RowValue>
</Row>
</Card>
<Card>
<CardTitle>About</CardTitle>
<Row>
<RowLabel>Companion</RowLabel>
<RowValue>v1.0</RowValue>
</Row>
<Row>
<RowLabel>Dashboard</RowLabel>
<RowValue>Developer Dashboard</RowValue>
</Row>
</Card>
</Page>
);
}