From b63bf0f9e3794f58ec8d027cd9739b1adb99bf0d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 12 Apr 2026 00:13:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(web-settings):=20=E2=9C=A8=20Add=20Setting?= =?UTF-8?q?sPage=20component=20and=20route=20configuration=20for=20user=20?= =?UTF-8?q?settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- @applications/web/src/app/CompanionApp.tsx | 26 +- @applications/web/src/app/routes.tsx | 14 +- .../src/features/settings/SettingsPage.tsx | 501 ++++++++++++++++++ 3 files changed, 520 insertions(+), 21 deletions(-) create mode 100644 @applications/web/src/features/settings/SettingsPage.tsx diff --git a/@applications/web/src/app/CompanionApp.tsx b/@applications/web/src/app/CompanionApp.tsx index 4d01a78..1562b8b 100644 --- a/@applications/web/src/app/CompanionApp.tsx +++ b/@applications/web/src/app/CompanionApp.tsx @@ -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' }, ], }, diff --git a/@applications/web/src/app/routes.tsx b/@applications/web/src/app/routes.tsx index 1c5bd12..535562b 100644 --- a/@applications/web/src/app/routes.tsx +++ b/@applications/web/src/app/routes.tsx @@ -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 ( } /> + } /> + } /> } /> } /> } /> + } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> ); diff --git a/@applications/web/src/features/settings/SettingsPage.tsx b/@applications/web/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..d8b4791 --- /dev/null +++ b/@applications/web/src/features/settings/SettingsPage.tsx @@ -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` + font-size: 24px; + font-weight: 600; + color: ${({ theme }) => theme.colors.text.primary}; + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + +const Card = styled.div` + 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` + 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` + 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` + font-size: 14px; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const RowValue = styled.span` + 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` + -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` + font-size: 12px; + color: ${({ theme }) => theme.colors.text.secondary}; + min-width: 36px; + text-align: right; +`; + +const PersonaSelect = styled.select` + 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` + 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` + 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` + 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` + 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` + font-size: 13px; + color: ${({ theme }) => theme.colors.error}; + padding: 8px 16px; +`; + +const LoadingText = styled.p` + 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 ( + onChange(!on)} + role="switch" + aria-checked={on} + aria-label={label} + > + + + ); +} + +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(''); + const [applying, setApplying] = useState(false); + const [applyError, setApplyError] = useState(null); + const [applySuccess, setApplySuccess] = useState(false); + + const currentSessionId = sessionStorage.getItem('companion_session_id') ?? ''; + + const handleApplyPersona = useCallback(async (): Promise => { + 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 ( + + Settings + + + Voice Output (TTS) + + Enable TTS + + + + Volume + + setTtsVolume(Number(e.target.value))} + aria-label="TTS volume" + /> + {Math.round(ttsVolume * 100)}% + + + + + + Voice Input (STT) + + Enable microphone + + + + + + Persona + {personasLoading && Loading personas...} + {personasError && {personasError}} + {!personasLoading && !personasError && ( + <> + + Active +
+ {activePersona?.name ?? '—'} + {activePersona && active} +
+
+ + Switch to + { + setSelectedPersonaId(e.target.value); + setApplySuccess(false); + setApplyError(null); + }} + disabled={applying} + aria-label="Select persona" + > + + {personas.map((p) => ( + + ))} + + { void handleApplyPersona(); }} + disabled={applying || !selectedPersonaId} + > + {applying ? 'Applying...' : 'Apply'} + + {applySuccess && Session created} + + {applyError && {applyError}} + + )} +
+ + + Push Notifications + + Status +
+ + {pushSubscribed + ? 'Subscribed' + : pushDenied + ? 'Blocked' + : pushStatus === 'unsupported' + ? 'Unsupported' + : 'Not subscribed'} + + {!pushSubscribed && ( + { void subscribe(); } : undefined} + disabled={pushDisabled} + aria-label={pushLabel(pushStatus)} + > + {pushLabel(pushStatus)} + + )} +
+
+
+ + + Session + + Session ID +
+ + {currentSessionId ? `${currentSessionId.slice(0, 16)}…` : '—'} + + {currentSessionId && ( + + Copy + + )} +
+
+
+ + + Environment + + API Base URL + {API_BASE_URL} + + + AI API Base URL + {AI_API_BASE_URL} + + + Socket Base URL + {SOCKET_BASE_URL || '(origin)'} + + + + + About + + Companion + v1.0 + + + Dashboard + Developer Dashboard + + +
+ ); +}