diff --git a/e2e/settings-responsive.spec.ts b/e2e/settings-responsive.spec.ts new file mode 100644 index 00000000..43fa359b --- /dev/null +++ b/e2e/settings-responsive.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' + +const fixture = '/e2e-fixtures/settings-fixture.html' + +test.describe('settings responsive layout', () => { + test('mobile drills from the category hub into a full-width detail page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(fixture) + + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible() + await expect(page.getByRole('button', { name: /Display/ })).toBeVisible() + await expect(page.locator('aside')).toBeHidden() + + await page.getByRole('button', { name: /Display/ }).click() + + await expect(page.locator('header').getByRole('heading', { name: 'Display' })).toBeVisible() + await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible() + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden() + await expect(page.getByRole('listbox')).toHaveCount(0) + }) + + test('desktop keeps navigation visible and renders Display as the default detail', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(fixture) + + const desktopNav = page.locator('aside nav') + await expect(desktopNav).toBeVisible() + await expect(desktopNav.getByRole('button', { name: 'Display' })).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('heading', { name: 'Display' })).toBeVisible() + await expect(page.getByText('Appearance, typography, and session list preferences.')).toBeVisible() + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeHidden() + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(page.getByText('Choose a category to adjust HAPI to your workflow.')).toBeVisible() + await expect(page.locator('aside')).toBeHidden() + }) +}) diff --git a/web/README.md b/web/README.md index 2f6ee5ea..3498263f 100644 --- a/web/README.md +++ b/web/README.md @@ -30,7 +30,14 @@ See `src/router.tsx` for route definitions. - `/sessions/$sessionId/files` - File browser with git status. - `/sessions/$sessionId/file` - File viewer with diff support. - `/sessions/$sessionId/terminal` - Terminal interface. -- `/settings` - Application settings. +- `/settings` - Settings category hub (mobile) and responsive master-detail shell. +- `/settings/general` - Language preferences. +- `/settings/display` - Appearance, typography, colors, and session list preferences. +- `/settings/chat` - Message input, tool card, and conversation color preferences. +- `/settings/voice` - Everyday voice assistant preferences. +- `/settings/voice/voices` - Full-page voice picker. +- `/settings/voice/advanced` - Voice persona, tuning, and diagnostics. +- `/settings/about` - Application links and version information. ## Features diff --git a/web/e2e-fixtures/settings-fixture.html b/web/e2e-fixtures/settings-fixture.html new file mode 100644 index 00000000..10865e1c --- /dev/null +++ b/web/e2e-fixtures/settings-fixture.html @@ -0,0 +1,12 @@ + + + + + + Settings responsive fixture + + +
+ + + diff --git a/web/e2e-fixtures/settings-fixture.tsx b/web/e2e-fixtures/settings-fixture.tsx new file mode 100644 index 00000000..fa30dac6 --- /dev/null +++ b/web/e2e-fixtures/settings-fixture.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import SettingsLayout from '../src/routes/settings/layout' +import SettingsHubPage from '../src/routes/settings' +import SettingsDisplayPage from '../src/routes/settings/display' + +const rootRoute = createRootRoute({ + component: () => , +}) + +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsLayout, +}) + +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: SettingsHubPage, +}) + +const settingsDisplayRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'display', + component: SettingsDisplayPage, +}) + +const routeTree = rootRoute.addChildren([ + settingsRoute.addChildren([settingsIndexRoute, settingsDisplayRoute]), +]) + +const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/settings'] }), +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const root = document.getElementById('root') +if (root) { + ReactDOM.createRoot(root).render( + + + + + + ) +} diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx new file mode 100644 index 00000000..3abb26b3 --- /dev/null +++ b/web/src/components/settings/SettingsNav.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from '@/lib/use-translation' +import { useAppearance } from '@/hooks/useTheme' +import { useFontScale } from '@/hooks/useFontScale' +import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' +import { settingsCategories } from '@/routes/settings/categories' +import { ChevronRightIcon } from './SettingsPrimitives' + +export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { + const navigate = useNavigate() + const { t, locale } = useTranslation() + const { appearance } = useAppearance() + const { fontScale } = useFontScale() + const { composerEnterBehavior } = useComposerEnterBehavior() + + const summaries: Record = { + general: locale === 'zh-CN' ? '简体中文' : 'English', + display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, + chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), + voice: t('settings.hub.voice.summary'), + about: `v${__APP_VERSION__}`, + } + + return ( + + ) +} diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx new file mode 100644 index 00000000..fd13116b --- /dev/null +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react' + +export function ChevronRightIcon(props: { className?: string }) { + return ( + + ) +} + +export function CheckIcon(props: { className?: string }) { + return ( + + ) +} + +export function SettingsPageContent(props: { title: string; description?: string; children: ReactNode }) { + return ( +
+
+

{props.title}

+ {props.description ?

{props.description}

: null} +
+ {props.children} +
+ ) +} + +export function SettingsSection(props: { title?: string; description?: string; children: ReactNode }) { + return ( +
+ {props.title ?

{props.title}

: null} + {props.description ?

{props.description}

: null} +
+ {props.children} +
+
+ ) +} + +export function SettingsRow(props: { label: string; description?: string; trailing?: ReactNode; children?: ReactNode }) { + return ( +
+
+
{props.label}
+ {props.description ?
{props.description}
: null} + {props.children} +
+ {props.trailing ?
{props.trailing}
: null} +
+ ) +} + +export function SettingsSwitch(props: { label: string; description?: string; checked: boolean; onChange: (checked: boolean) => void }) { + return ( + + props.onChange(event.target.checked)} className="peer sr-only" aria-label={props.label} /> + + + + } /> + ) +} + +export function SettingsChoiceGroup(props: { + label: string + value: T + options: ReadonlyArray<{ value: T; label: string; description?: string }> + onChange: (value: T) => void + columns?: 2 | 4 | 5 +}) { + const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + return ( +
+ {props.label} +
+ {props.options.map((option) => { + const selected = props.value === option.value + return ( + + ) + })} +
+
+ ) +} + +export function SettingsLinkRow(props: { label: string; value?: string; description?: string; onClick: () => void }) { + return ( + + ) +} diff --git a/web/src/hooks/useAppGoBack.test.ts b/web/src/hooks/useAppGoBack.test.ts new file mode 100644 index 00000000..91101ff3 --- /dev/null +++ b/web/src/hooks/useAppGoBack.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { getSettingsBackTarget } from './useAppGoBack' + +describe('getSettingsBackTarget', () => { + it.each([ + ['/settings', '/sessions'], + ['/settings/general', '/settings'], + ['/settings/display', '/settings'], + ['/settings/voice', '/settings'], + ['/settings/voice/voices', '/settings/voice'], + ['/settings/voice/advanced', '/settings/voice'], + ['/sessions', null], + ])('maps %s to %s', (pathname, target) => { + expect(getSettingsBackTarget(pathname)).toBe(target) + }) +}) diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts index 98361669..470e95d7 100644 --- a/web/src/hooks/useAppGoBack.ts +++ b/web/src/hooks/useAppGoBack.ts @@ -1,6 +1,13 @@ import { useCallback } from 'react' import { useLocation, useNavigate, useRouter } from '@tanstack/react-router' +export function getSettingsBackTarget(pathname: string): string | null { + if (pathname === '/settings') return '/sessions' + if (pathname === '/settings/voice/advanced' || pathname === '/settings/voice/voices') return '/settings/voice' + if (pathname.startsWith('/settings/')) return '/settings' + return null +} + export function useAppGoBack(): () => void { const navigate = useNavigate() const router = useRouter() @@ -14,9 +21,10 @@ export function useAppGoBack(): () => void { return } - // Settings page always goes back to sessions - if (pathname === '/settings') { - navigate({ to: '/sessions' }) + // Settings uses explicit parent routes so mobile drill-down remains predictable. + const settingsBackTarget = getSettingsBackTarget(pathname) + if (settingsBackTarget) { + navigate({ to: settingsBackTarget }) return } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 6a70603d..6e24f108 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -545,10 +545,18 @@ export default { 'language.chinese': '简体中文', // Settings page + 'common.back': 'Back', 'settings.title': 'Settings', + 'settings.hub.description': 'Choose a category to adjust HAPI to your workflow.', + 'settings.hub.voice.summary': 'Voice, language, and behavior', + 'settings.general.title': 'General', + 'settings.general.description': 'Language and general application preferences.', 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.description': 'Appearance, typography, and session list preferences.', + 'settings.display.typography': 'Typography', + 'settings.display.sessions': 'Session list', 'settings.display.appearance': 'Appearance', 'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.dark': 'Dark', @@ -558,6 +566,7 @@ export default { 'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.', 'settings.display.themeColors.reset': 'Reset', 'settings.display.themeColors.resetAll': 'Reset all', + 'settings.display.themeColors.expand': 'Customize', 'settings.display.themeColors.key.background': 'Background', 'settings.display.themeColors.key.surface': 'Cards & surfaces', 'settings.display.themeColors.key.text': 'Text', @@ -577,6 +586,10 @@ export default { 'settings.display.sessionListStatus.detailed': 'Detailed', 'settings.display.sessionListStatus.detailedDescription': 'Shows why a session stopped: permission, input, background work, new activity, or a scheduled message (clock icon).', 'settings.chat.title': 'Chat', + 'settings.chat.description': 'Message input, tool cards, and conversation colors.', + 'settings.chat.input': 'Input', + 'settings.chat.tools': 'Tool cards', + 'settings.chat.colors': 'Conversation colors', 'settings.chat.enterBehavior': 'Enter Key', 'settings.chat.enterBehavior.send': 'Send message', 'settings.chat.enterBehavior.newline': 'Insert newline', @@ -591,6 +604,8 @@ export default { 'settings.chat.surfaceColor.softYellow': 'Soft yellow', 'settings.chat.surfaceColor.custom': 'Custom color', 'settings.voice.title': 'Voice Assistant', + 'settings.voice.description': 'Voice connection, language, and everyday behavior.', + 'settings.voice.voices.description': 'Choose the voice used by the selected backend.', 'settings.voice.sounds.title': 'How It Sounds', 'settings.voice.responds.title': 'How It Responds', 'settings.voice.connection.title': 'Connection & provider', @@ -671,11 +686,14 @@ export default { 'settings.voice.tuning.selectBackend': 'Per-backend sliders appear when a voice backend is selected.', 'settings.voice.preview.elevenlabsOnly': 'Voice preview is only available for ElevenLabs', 'settings.voice.preview.unavailable': 'Preview unavailable without an ElevenLabs API key', + 'settings.voice.preview.play': 'Preview voice', + 'settings.voice.preview.stop': 'Stop preview', 'settings.voice.staticCatalogHint': 'Open the list to see voice character notes. Audio preview is ElevenLabs only.', 'settings.voice.session.label': 'Session behavior', 'settings.voice.proactive': 'Start voice session with summary', 'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.', 'settings.about.title': 'About', + 'settings.about.description': 'HAPI links and version information.', 'settings.about.website': 'Website', 'settings.about.appVersion': 'App Version', 'settings.about.protocolVersion': 'Protocol Version', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index de5e5f62..0fe0ad31 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -549,10 +549,18 @@ export default { 'language.chinese': '简体中文', // Settings page + 'common.back': '返回', 'settings.title': '设置', + 'settings.hub.description': '选择一个分类,按你的工作方式调整 HAPI。', + 'settings.hub.voice.summary': '声音、语言和行为', + 'settings.general.title': '通用', + 'settings.general.description': '语言和通用应用偏好。', 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.description': '外观、字体和会话列表偏好。', + 'settings.display.typography': '字体', + 'settings.display.sessions': '会话列表', 'settings.display.appearance': '外观', 'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.dark': '深色', @@ -562,6 +570,7 @@ export default { 'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。', 'settings.display.themeColors.reset': '重置', 'settings.display.themeColors.resetAll': '全部重置', + 'settings.display.themeColors.expand': '自定义', 'settings.display.themeColors.key.background': '背景', 'settings.display.themeColors.key.surface': '卡片与表面', 'settings.display.themeColors.key.text': '文字', @@ -581,6 +590,10 @@ export default { 'settings.display.sessionListStatus.detailed': '详细', 'settings.display.sessionListStatus.detailedDescription': '显示会话停止的原因:权限、输入、后台任务、新活动或定时消息(时钟图标)。', 'settings.chat.title': '聊天', + 'settings.chat.description': '消息输入、工具卡片和对话颜色。', + 'settings.chat.input': '输入', + 'settings.chat.tools': '工具卡片', + 'settings.chat.colors': '对话颜色', 'settings.chat.enterBehavior': '回车键行为', 'settings.chat.enterBehavior.send': '发送消息', 'settings.chat.enterBehavior.newline': '插入换行', @@ -595,6 +608,8 @@ export default { 'settings.chat.surfaceColor.softYellow': '柔和黄', 'settings.chat.surfaceColor.custom': '自定义颜色', 'settings.voice.title': '语音助手', + 'settings.voice.description': '语音连接、语言和常用行为。', + 'settings.voice.voices.description': '选择当前后端使用的声音。', 'settings.voice.sounds.title': '声音效果', 'settings.voice.responds.title': '对话风格', 'settings.voice.connection.title': '连接与提供商', @@ -675,11 +690,14 @@ export default { 'settings.voice.tuning.selectBackend': '选择语音后端后显示对应滑块。', 'settings.voice.preview.elevenlabsOnly': '仅 ElevenLabs 支持试听', 'settings.voice.preview.unavailable': '未配置 ElevenLabs API 密钥时无法试听', + 'settings.voice.preview.play': '预览声音', + 'settings.voice.preview.stop': '停止预览', 'settings.voice.staticCatalogHint': '展开列表可查看声音特点说明;仅 ElevenLabs 支持试听。', 'settings.voice.session.label': '会话行为', 'settings.voice.proactive': '以摘要开始语音会话', 'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。', 'settings.about.title': '关于', + 'settings.about.description': 'HAPI 链接和版本信息。', 'settings.about.website': '官方网站', 'settings.about.appVersion': '应用版本', 'settings.about.protocolVersion': '协议版本', diff --git a/web/src/router.tsx b/web/src/router.tsx index ec6ea6f6..067c806f 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -46,7 +46,15 @@ import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } fr import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' -import SettingsPage from '@/routes/settings' +import SettingsLayout from '@/routes/settings/layout' +import SettingsHubPage from '@/routes/settings' +import SettingsGeneralPage from '@/routes/settings/general' +import SettingsDisplayPage from '@/routes/settings/display' +import SettingsChatPage from '@/routes/settings/chat' +import SettingsVoicePage from '@/routes/settings/voice' +import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices' +import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced' +import SettingsAboutPage from '@/routes/settings/about' import SharePage from '@/routes/share' import { setSharePendingTransfer } from '@/lib/sharePendingState' import { deleteShareTransfer } from '@/lib/shareTransfer' @@ -1226,7 +1234,55 @@ const browseRoute = createRoute({ const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', - component: SettingsPage, + component: SettingsLayout, +}) + +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: SettingsHubPage, +}) + +const settingsGeneralRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'general', + component: SettingsGeneralPage, +}) + +const settingsDisplayRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'display', + component: SettingsDisplayPage, +}) + +const settingsChatRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'chat', + component: SettingsChatPage, +}) + +const settingsVoiceRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice', + component: SettingsVoicePage, +}) + +const settingsVoiceVoicesRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice/voices', + component: SettingsVoiceVoicesPage, +}) + +const settingsVoiceAdvancedRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'voice/advanced', + component: SettingsVoiceAdvancedPage, +}) + +const settingsAboutRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'about', + component: SettingsAboutPage, }) // Web Share Target landing route. Service worker (`web/src/sw.ts`) @@ -1260,7 +1316,16 @@ export const routeTree = rootRoute.addChildren([ ]), ]), browseRoute, - settingsRoute, + settingsRoute.addChildren([ + settingsIndexRoute, + settingsGeneralRoute, + settingsDisplayRoute, + settingsChatRoute, + settingsVoiceRoute, + settingsVoiceVoicesRoute, + settingsVoiceAdvancedRoute, + settingsAboutRoute, + ]), shareRoute, ]) diff --git a/web/src/routes/settings/about.tsx b/web/src/routes/settings/about.tsx new file mode 100644 index 00000000..680ebbe7 --- /dev/null +++ b/web/src/routes/settings/about.tsx @@ -0,0 +1,18 @@ +import { PROTOCOL_VERSION } from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives' + +export default function SettingsAboutPage() { + const { t } = useTranslation() + return ( + + + hapi.run + } /> + {__APP_VERSION__}
} /> + {PROTOCOL_VERSION}
} /> + + + ) +} diff --git a/web/src/routes/settings/categories.ts b/web/src/routes/settings/categories.ts new file mode 100644 index 00000000..1eb1aec6 --- /dev/null +++ b/web/src/routes/settings/categories.ts @@ -0,0 +1,13 @@ +export const settingsCategories = [ + { id: 'general', path: '/settings/general', titleKey: 'settings.general.title' }, + { id: 'display', path: '/settings/display', titleKey: 'settings.display.title' }, + { id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' }, + { id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' }, + { id: 'about', path: '/settings/about', titleKey: 'settings.about.title' }, +] as const + +export type SettingsCategory = typeof settingsCategories[number] + +export function getSettingsCategory(pathname: string): SettingsCategory | undefined { + return settingsCategories.find((category) => pathname === category.path || pathname.startsWith(`${category.path}/`)) +} diff --git a/web/src/routes/settings/chat.tsx b/web/src/routes/settings/chat.tsx new file mode 100644 index 00000000..6f44beea --- /dev/null +++ b/web/src/routes/settings/chat.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from '@/lib/use-translation' +import { getComposerEnterBehaviorOptions, useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' +import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' +import { + getChatSurfaceColorPickerValue, + getChatSurfaceColorPresetOptions, + toCustomChatSurfaceColorPreference, + toPresetChatSurfaceColorPreference, + useChatSurfaceColors, + type ChatSurfaceColorPreference, + type ChatSurfaceColorPreset, +} from '@/hooks/useChatSurfaceColors' +import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' + +function ChatSurfaceColorControl(props: { + label: string + preference: ChatSurfaceColorPreference + onPresetChange: (preset: ChatSurfaceColorPreset) => void + onCustomChange: (value: string) => void +}) { + const { t } = useTranslation() + const pickerValue = getChatSurfaceColorPickerValue(props.preference) + return ( +
+ {props.label} +
+ {getChatSurfaceColorPresetOptions().map((option) => { + const preference = toPresetChatSurfaceColorPreference(option.value) + const selected = props.preference === preference + return ( + + ) + })} +
+ +
+ ) +} + +export default function SettingsChatPage() { + const { t } = useTranslation() + const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() + const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() + const { toolGroupBackground, userMessageBackground, setToolGroupBackground, setUserMessageBackground } = useChatSurfaceColors() + return ( + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setComposerEnterBehavior} + /> + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setTerminalToolDisplayMode} + /> + + + setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} /> + setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} /> + + + ) +} diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx new file mode 100644 index 00000000..ffc5c427 --- /dev/null +++ b/web/src/routes/settings/display.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from '@/lib/use-translation' +import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme' +import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale' +import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize' +import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' +import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, normalizeSessionPreviewLimit, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' +import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' +import { SettingsChoiceGroup, SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' + +function MinusIcon() { + return +} + +function PlusIcon() { + return +} + +function SessionPreviewLimitControl() { + const { t } = useTranslation() + const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit() + const [draft, setDraft] = useState(String(sessionPreviewLimit)) + + useEffect(() => setDraft(String(sessionPreviewLimit)), [sessionPreviewLimit]) + + const commit = () => { + const parsed = draft.trim() === '' ? sessionPreviewLimit : Number(draft) + const next = normalizeSessionPreviewLimit(parsed) + setSessionPreviewLimit(next) + setDraft(String(next)) + } + const step = (delta: number) => setSessionPreviewLimit(normalizeSessionPreviewLimit(sessionPreviewLimit + delta)) + + return ( + + + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === 'Enter') { commit(); event.currentTarget.blur() } + if (event.key === 'Escape') { setDraft(String(sessionPreviewLimit)); event.currentTarget.blur() } + }} + className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm text-[var(--app-fg)] outline-none" + /> + + + } /> + ) +} + +function ThemeColorControls() { + const { t } = useTranslation() + const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors() + return ( +
+ + + {t('settings.display.themeColors.title')} + {t('settings.display.themeColors.description')} + + {hasAnyCustom ? t('settings.voice.advanced.customizedBadge') : t('settings.display.themeColors.expand')} + +
+ {hasAnyCustom ?
: null} +
+ {keys.map((key) => ( +
+ {t(key.labelKey)} + + {isCustomized(key.id) ? : null} + setColor(key.id as ThemeColorKeyId, event.target.value)} className="h-8 w-10 cursor-pointer border-0 bg-transparent p-0" /> + +
+ ))} +
+
+
+ ) +} + +export default function SettingsDisplayPage() { + const { t } = useTranslation() + const { appearance, setAppearance } = useAppearance() + const { fontScale, setFontScale } = useFontScale() + const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() + const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() + const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() + + return ( + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setAppearance} + /> + + + + + + + + + + + + ({ value: option.value, label: t(option.labelKey) }))} + onChange={setSessionListStatusMode} + /> + {sessionListStatusMode === 'detailed' ?
{t('settings.display.sessionListStatus.detailedDescription')}
: null} +
+
+ ) +} diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx new file mode 100644 index 00000000..7197fab9 --- /dev/null +++ b/web/src/routes/settings/general.tsx @@ -0,0 +1,18 @@ +import { useTranslation, type Locale } from '@/lib/use-translation' +import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' + +const locales: ReadonlyArray<{ value: Locale; label: string }> = [ + { value: 'en', label: 'English' }, + { value: 'zh-CN', label: '简体中文' }, +] + +export default function SettingsGeneralPage() { + const { t, locale, setLocale } = useTranslation() + return ( + + + + + + ) +} diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index cd437768..e1698b67 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,42 +1,89 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react' -import { I18nContext, I18nProvider } from '@/lib/i18n-context' -import { en } from '@/lib/locales' -import { PROTOCOL_VERSION } from '@hapi/protocol' -import SettingsPage from './index' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import SettingsHubPage from './index' +import SettingsGeneralPage from './general' +import SettingsDisplayPage from './display' +import SettingsChatPage from './chat' +import SettingsAboutPage from './about' +import SettingsVoicePage from './voice' +import SettingsVoiceVoicesPage from './voice-voices' +import SettingsVoiceAdvancedPage from './voice-advanced' -vi.mock('@hapi/protocol', () => ({ - PROTOCOL_VERSION: 1, +const { navigate, setAppearance, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({ + navigate: vi.fn(), + setAppearance: vi.fn(), + setFontScale: vi.fn(), + setTerminalFontSize: vi.fn(), + setComposerEnterBehavior: vi.fn(), + setVoice: vi.fn(), })) -// Mock the router hooks vi.mock('@tanstack/react-router', () => ({ - useNavigate: () => vi.fn(), - useRouter: () => ({ history: { back: vi.fn() } }), - useLocation: () => '/settings', + useNavigate: () => navigate, +})) + +vi.mock('@hapi/protocol', () => ({ PROTOCOL_VERSION: 1 })) + +vi.mock('@/hooks/useTheme', () => ({ + useAppearance: () => ({ appearance: 'system', setAppearance }), + getAppearanceOptions: () => [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'oled', labelKey: 'settings.display.appearance.oled' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ], })) -// Mock useFontScale hook vi.mock('@/hooks/useFontScale', () => ({ - useFontScale: () => ({ fontScale: 1, setFontScale: vi.fn() }), + useFontScale: () => ({ fontScale: 1, setFontScale }), getFontScaleOptions: () => [ - { value: 0.875, label: '87.5%' }, - { value: 1, label: '100%' }, - { value: 1.125, label: '112.5%' }, + { value: 0.8, label: '80%' }, { value: 0.9, label: '90%' }, { value: 1, label: '100%' }, + { value: 1.1, label: '110%' }, { value: 1.2, label: '120%' }, ], })) vi.mock('@/hooks/useTerminalFontSize', () => ({ - useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize: vi.fn() }), + useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize }), getTerminalFontSizeOptions: () => [ - { value: 9, label: '9px' }, - { value: 13, label: '13px' }, - { value: 17, label: '17px' }, + { value: 9, label: '9px' }, { value: 11, label: '11px' }, { value: 13, label: '13px' }, + { value: 15, label: '15px' }, { value: 17, label: '17px' }, ], })) +vi.mock('@/hooks/useSessionListStatusMode', () => ({ + useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }), + getSessionListStatusModeOptions: () => [ + { value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' }, + { value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' }, + ], +})) + +vi.mock('@/hooks/useShowActiveSessionsOnly', () => ({ + useShowActiveSessionsOnly: () => ({ showActiveSessionsOnly: false, setShowActiveSessionsOnly: vi.fn() }), +})) + +vi.mock('@/hooks/useSessionPreviewLimit', () => ({ + MIN_SESSION_PREVIEW_LIMIT: 1, + MAX_SESSION_PREVIEW_LIMIT: 99, + normalizeSessionPreviewLimit: (value: number) => Math.max(1, Math.min(99, Math.round(value))), + useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }), +})) + +vi.mock('@/hooks/useThemeColors', () => ({ + useThemeColors: () => ({ + keys: [], + getPickerValue: vi.fn(), + isCustomized: vi.fn(() => false), + hasAnyCustom: false, + setColor: vi.fn(), + resetColor: vi.fn(), + resetAll: vi.fn(), + }), +})) + vi.mock('@/hooks/useComposerEnterBehavior', () => ({ - useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior: vi.fn() }), + useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send', setComposerEnterBehavior }), getComposerEnterBehaviorOptions: () => [ { value: 'send', labelKey: 'settings.chat.enterBehavior.send' }, { value: 'newline', labelKey: 'settings.chat.enterBehavior.newline' }, @@ -51,21 +98,6 @@ vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({ ], })) -vi.mock('@/hooks/useSessionListStatusMode', () => ({ - useSessionListStatusMode: () => ({ sessionListStatusMode: 'standard', setSessionListStatusMode: vi.fn() }), - getSessionListStatusModeOptions: () => [ - { value: 'standard', labelKey: 'settings.display.sessionListStatus.standard' }, - { value: 'detailed', labelKey: 'settings.display.sessionListStatus.detailed' }, - ], -})) - -vi.mock('@/hooks/useSessionPreviewLimit', () => ({ - MIN_SESSION_PREVIEW_LIMIT: 1, - MAX_SESSION_PREVIEW_LIMIT: 99, - normalizeSessionPreviewLimit: (value: number) => Number.isInteger(value) ? Math.min(99, Math.max(1, value)) : 8, - useSessionPreviewLimit: () => ({ sessionPreviewLimit: 8, setSessionPreviewLimit: vi.fn() }), -})) - vi.mock('@/hooks/useChatSurfaceColors', () => ({ useChatSurfaceColors: () => ({ toolGroupBackground: 'default', @@ -76,440 +108,108 @@ vi.mock('@/hooks/useChatSurfaceColors', () => ({ getChatSurfaceColorPresetOptions: () => [ { value: 'default', labelKey: 'settings.chat.surfaceColor.default' }, { value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' }, - { value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' }, - { value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' }, ], getChatSurfaceColorPickerValue: () => '#7db7ff', toPresetChatSurfaceColorPreference: (value: string) => value === 'default' ? 'default' : `preset:${value}`, toCustomChatSurfaceColorPreference: (value: string) => `custom:${value}`, })) -// Mock useTheme hook -vi.mock('@/hooks/useTheme', () => ({ - useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), - getAppearanceOptions: () => [ - { value: 'system', labelKey: 'settings.display.appearance.system' }, - { value: 'dark', labelKey: 'settings.display.appearance.dark' }, - { value: 'light', labelKey: 'settings.display.appearance.light' }, - ], +vi.mock('@/components/settings/VoiceAdvancedControls', () => ({ + VoiceRespondsControls: () =>
Response length controls
, + VoiceSoundsControls: () =>
Sound controls
, + VoicePersonaControls: () =>
Persona controls
, + VoiceDiagnosticsControls: () =>
Diagnostics controls
, })) -// Mock languages -vi.mock('@/lib/languages', () => ({ - getElevenLabsSupportedLanguages: () => [ - { code: null, name: 'Auto-detect' }, - { code: 'en', name: 'English' }, - ], - getLanguageDisplayName: (lang: { code: string | null; name: string }) => lang.name, +vi.mock('./useVoiceSettings', () => ({ + useVoiceSettings: () => ({ + configuredBackends: ['elevenlabs'], + backend: 'elevenlabs', + setBackend: vi.fn(), + voiceId: null, + setVoice, + voices: [ + { id: 'voice-1', name: 'Jessica', description: 'Warm', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ], + voiceLanguage: null, + setVoiceLanguage: vi.fn(), + voiceLanguages: [{ code: null, name: 'Auto-detect' }, { code: 'en', name: 'English' }], + playingVoiceId: null, + previewVoice: vi.fn(), + }), })) -// Use vi.hoisted so these mocks are available when vi.mock factories run -const { mockFetchVoices, mockFetchVoiceBackend, mockApi } = vi.hoisted(() => { - const mockFetchVoices = vi.fn(() => Promise.resolve([])) - const mockFetchVoiceBackend = vi.fn(() => Promise.resolve({ - backend: 'elevenlabs' as 'elevenlabs' | 'gemini-live' | 'qwen-realtime', - backends: ['elevenlabs'] as Array<'elevenlabs' | 'gemini-live' | 'qwen-realtime'> - })) - const mockApi = { - fetchVoices: vi.fn(() => Promise.resolve({ voices: [] })), - } - return { mockFetchVoices, mockFetchVoiceBackend, mockApi } -}) - -// Mock static voices list -vi.mock('@/lib/voices', () => ({ - VOICES: [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }], - DEFAULT_VOICE_ID: 'voice1', - getVoiceById: (id: string | null) => - id === 'voice1' ? { id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' } : undefined, - getFallbackVoices: () => [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }], -})) - -// Mock fetchVoices to return a resolved list by default -vi.mock('@/api/voice', () => ({ - fetchVoices: mockFetchVoices, - fetchVoiceBackend: mockFetchVoiceBackend, - fetchVoiceToken: vi.fn(() => Promise.resolve({ allowed: true, token: 'tok' })), -})) - -// Mock useAppContext so the page doesn't throw "AppContext is not available" -vi.mock('@/lib/app-context', () => ({ - useAppContext: () => ({ api: mockApi, token: 'test', baseUrl: '' }), - AppContextProvider: ({ children }: { children: React.ReactNode }) => children, -})) - - -afterEach(() => { - cleanup() -}) -function renderWithProviders(ui: React.ReactElement) { - return render( - - {ui} - - ) +function renderPage(page: React.ReactElement) { + return render({page}) } -function renderWithSpyT(ui: React.ReactElement) { - const translations = en as Record - const spyT = vi.fn((key: string) => translations[key] ?? key) - render( - - {ui} - - ) - return spyT -} - -describe('SettingsPage', () => { +describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() - mockFetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) - // Reset fetchVoices mock to return empty list by default - mockFetchVoices.mockResolvedValue([]) - // Mock localStorage - const localStorageMock = { - getItem: vi.fn(() => null), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(() => null), - length: 0, - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + localStorage.clear() }) - it('renders the About section', () => { - renderWithProviders() - expect(screen.getByText('About')).toBeInTheDocument() + it('renders the mobile hub categories with current summaries', () => { + renderPage() + expect(screen.getByText('General')).toBeInTheDocument() + expect(screen.getAllByText('Display').length).toBeGreaterThan(0) + expect(screen.getByText('Voice, language, and behavior')).toBeInTheDocument() + expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument() }) - it('displays the App Version with correct value', () => { - renderWithProviders() - expect(screen.getAllByText('App Version').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText(__APP_VERSION__).length).toBeGreaterThanOrEqual(1) + it('navigates from the hub to a category route', () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: /General/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/general' }) }) - it('displays the Protocol Version with correct value', () => { - renderWithProviders() - expect(screen.getAllByText('Protocol Version').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText(String(PROTOCOL_VERSION)).length).toBeGreaterThanOrEqual(1) + it('changes the application language inline', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: '简体中文' })) + expect(localStorage.getItem('hapi-lang')).toBe('zh-CN') }) - it('displays the website link with correct URL and security attributes', () => { - renderWithProviders() - expect(screen.getAllByText('Website').length).toBeGreaterThanOrEqual(1) - const links = screen.getAllByRole('link', { name: 'hapi.run' }) - expect(links.length).toBeGreaterThanOrEqual(1) - const link = links[0] - expect(link).toHaveAttribute('href', 'https://hapi.run') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + it('renders compact display controls without dropdown popovers', () => { + renderPage() + expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument() + expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() }) - it('uses correct i18n keys for About section', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.about.title') - expect(calledKeys).toContain('settings.about.website') - expect(calledKeys).toContain('settings.about.appVersion') - expect(calledKeys).toContain('settings.about.protocolVersion') + it('keeps chat enum choices inline', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: 'Insert newline' })) + expect(setComposerEnterBehavior).toHaveBeenCalledWith('newline') + expect(screen.getByText('Grouped Tool Use Background')).toBeInTheDocument() }) - it('renders the Appearance setting', () => { - renderWithProviders() - expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1) + it('renders About metadata on its own route page', () => { + renderPage() + expect(screen.getByText('App Version')).toBeInTheDocument() + expect(screen.getByText(String(__APP_VERSION__))).toBeInTheDocument() + expect(screen.getByText('Protocol Version')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'hapi.run' })).toHaveAttribute('rel', 'noopener noreferrer') }) - it('uses correct i18n keys for Appearance setting', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.display.appearance') - expect(calledKeys).toContain('settings.display.appearance.system') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease') - expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase') - expect(calledKeys).toContain('settings.display.sessionListStatus') - expect(calledKeys).toContain('settings.display.sessionListStatus.standard') + it('links common voice settings to full-page voices and advanced pages', () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: /Voice/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/voices' }) + fireEvent.click(screen.getByRole('button', { name: /Advanced voice settings/ })) + expect(navigate).toHaveBeenCalledWith({ to: '/settings/voice/advanced' }) }) - it('renders the Terminal Font Size setting', () => { - renderWithProviders() - expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1) + it('selects a voice from the full-page picker', () => { + renderPage() + fireEvent.click(screen.getByRole('radio', { name: /Jessica/ })) + expect(setVoice).toHaveBeenCalledWith('voice-1') }) - it('renders the Session Preview Limit setting', () => { - renderWithProviders() - expect(screen.getAllByText('Sessions Before Folding').length).toBeGreaterThanOrEqual(1) - expect(screen.getByLabelText('Sessions Before Folding')).toHaveValue(8) - expect(screen.getAllByLabelText('Show fewer sessions before folding').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByLabelText('Show more sessions before folding').length).toBeGreaterThanOrEqual(1) - }) - - it('renders the Session list status setting', () => { - renderWithProviders() - expect(screen.getAllByText('Session list status').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(1) - }) - - it('renders the Enter Key setting', () => { - renderWithProviders() - expect(screen.getAllByText('Enter Key').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Send message').length).toBeGreaterThanOrEqual(1) - }) - - it('renders the Terminal Tool Display setting', () => { - renderWithProviders() - expect(screen.getAllByText('Terminal Tool Cards').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Compact (command only)').length).toBeGreaterThanOrEqual(1) - }) - - it('renders grouped tool and user message background settings', () => { - renderWithProviders() - expect(screen.getAllByText('Grouped Tool Use Background').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('User Message Background').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Default color').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft blue').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft green').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Soft yellow').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByLabelText('Custom color').length).toBeGreaterThanOrEqual(2) - }) - - it('uses correct i18n keys for the Enter Key setting', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.chat.title') - expect(calledKeys).toContain('settings.chat.enterBehavior') - expect(calledKeys).toContain('settings.chat.enterBehavior.send') - expect(calledKeys).toContain('settings.chat.terminalToolDisplay') - expect(calledKeys).toContain('settings.chat.terminalToolDisplay.compact') - expect(calledKeys).toContain('settings.chat.groupedToolBackground') - expect(calledKeys).toContain('settings.chat.userMessageBackground') - expect(calledKeys).toContain('settings.chat.surfaceColor.default') - }) - - // Voice picker tests - it('renders the Voice section with "Voice" label', () => { - renderWithProviders() - expect(screen.getAllByText('Voice').length).toBeGreaterThanOrEqual(1) - }) - - it('uses correct i18n keys for the voice picker', () => { - const spyT = renderWithSpyT() - const calledKeys = spyT.mock.calls.map((call) => call[0]) - expect(calledKeys).toContain('settings.voice.voice') - expect(calledKeys).toContain('settings.voice.voiceDefault') - }) - - it('voice picker shows "Default" option when opened', () => { - renderWithProviders() - // The current value "Default" is shown in the closed picker button - expect(screen.getAllByText('Default').length).toBeGreaterThanOrEqual(1) - }) - - it('opens voice picker and shows "Default" option in the list', () => { - renderWithProviders() - // Click the voice picker button (aria-label target via the label text) - const voiceButtons = screen.getAllByRole('button', { name: /Default/i }) - // Find the button that has aria-haspopup — that's the voice picker trigger - const pickerButton = voiceButtons.find(btn => btn.getAttribute('aria-haspopup') === 'listbox') - expect(pickerButton).toBeTruthy() - fireEvent.click(pickerButton!) - // The listbox should appear with a "Default" option inside - const listbox = screen.getByRole('listbox', { name: 'Voice' }) - expect(listbox).toBeInTheDocument() - expect(listbox.textContent).toContain('Default') - }) - - it('shows dynamic voices in picker when fetchVoices returns a list', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - { id: 'dyn2', name: 'Bob', previewUrl: 'https://example.com/bob.mp3', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await waitFor(() => { - expect(screen.getByText('Alice')).toBeInTheDocument() - expect(screen.getByText('Bob')).toBeInTheDocument() - }) - }) - - - it('shows a disabled preview button with tooltip when previewUrl is missing', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const previewButton = await screen.findByLabelText('Preview voice') - expect(previewButton).toBeDisabled() - expect(previewButton).toHaveAttribute('title', 'Preview unavailable without an ElevenLabs API key') - }) - - it('shows a play button for voices with a previewUrl', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await screen.findByText('Alice') - expect(screen.getByLabelText('Preview voice')).toBeInTheDocument() - expect(screen.getByLabelText('Preview voice')).not.toBeDisabled() - }) - - it('stops preview audio on unmount', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' }, - ]) - - const pause = vi.fn() - const play = vi.fn(() => Promise.resolve()) - const addEventListener = vi.fn() - class MockAudio { - pause = pause - play = play - addEventListener = addEventListener - constructor(_url: string) {} - } - const OriginalAudio = globalThis.Audio - const OriginalWindowAudio = window.Audio - // @ts-expect-error test override - globalThis.Audio = MockAudio - // @ts-expect-error test override - window.Audio = MockAudio - - const view = renderWithProviders() - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - const aliceLabel = await screen.findByText('Alice') - const optionRow = aliceLabel.closest('[role="option"]') - expect(optionRow).toBeTruthy() - const enabledPreview = optionRow?.querySelector('button[aria-label="Preview voice"]') as HTMLButtonElement | null - expect(enabledPreview).toBeTruthy() - expect(enabledPreview?.disabled).toBe(false) - fireEvent.click(enabledPreview as HTMLElement) - - view.unmount() - expect(pause).toHaveBeenCalled() - - globalThis.Audio = OriginalAudio - window.Audio = OriginalWindowAudio - }) - - it('selecting a voice calls localStorage.setItem with the voice id', async () => { - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const alice = await screen.findByText('Alice') - fireEvent.click(alice) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-elevenlabs', 'dyn1') - }) - - it('shows Gemini static voices when hub backend is gemini-live', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - await waitFor(() => { - expect(screen.getByText('Puck')).toBeInTheDocument() - expect(screen.getByText('Aoede')).toBeInTheDocument() - expect(screen.getByText('Conversational, friendly')).toBeInTheDocument() - }) - expect(mockFetchVoices).not.toHaveBeenCalled() - }) - - it('shows static catalog hint when Gemini backend is selected', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('Open the list to see voice character notes. Audio preview is ElevenLabs only.')).toBeInTheDocument() - }) - }) - - it('persists Gemini voice selection under gemini storage key', async () => { - mockFetchVoiceBackend.mockResolvedValue({ backend: 'gemini-live', backends: ['gemini-live'] }) - - renderWithProviders() - - const pickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(pickerButton) - - const puck = await screen.findByText('Puck') - fireEvent.click(puck) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-gemini', 'Puck') - }) - - it('shows backend chooser when hub has multiple configured backends', async () => { - mockFetchVoiceBackend.mockResolvedValue({ - backend: 'gemini-live', - backends: ['elevenlabs', 'gemini-live'] - }) - ;(window.localStorage.getItem as ReturnType).mockImplementation((key: string) => { - if (key === 'hapi-voice-backend') return 'elevenlabs' - return null - }) - - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('Voice backend')).toBeInTheDocument() - expect(screen.getByText('Voice Assistant')).toBeInTheDocument() - expect(screen.getByText('Connection & provider')).toBeInTheDocument() - }) - - const backendButton = screen.getByRole('button', { name: /Voice backend\s*ElevenLabs/i }) - fireEvent.click(backendButton) - - const listbox = screen.getByRole('listbox', { name: 'Voice backend' }) - expect(listbox.textContent).toContain('Gemini Live') - }) - - it('switches to ElevenLabs voices when backend chooser selects elevenlabs', async () => { - mockFetchVoiceBackend.mockResolvedValue({ - backend: 'gemini-live', - backends: ['elevenlabs', 'gemini-live'] - }) - mockFetchVoices.mockResolvedValue([ - { id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' }, - ]) - - renderWithProviders() - - const backendButton = await screen.findByRole('button', { name: /Voice backend/i }) - fireEvent.click(backendButton) - fireEvent.click(screen.getByRole('option', { name: /ElevenLabs/i })) - - const voicePickerButton = await screen.findByRole('button', { name: /Voice\s*Default/i }) - fireEvent.click(voicePickerButton) - - await waitFor(() => { - expect(screen.getByText('Alice')).toBeInTheDocument() - }) - expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-backend', 'elevenlabs') + it('keeps persona, tuning, and diagnostics on the advanced route page', () => { + renderPage() + expect(screen.getByText('Persona controls')).toBeInTheDocument() + expect(screen.getByText('Sound controls')).toBeInTheDocument() + expect(screen.getByText('Diagnostics controls')).toBeInTheDocument() }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 25603362..3436a85c 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -1,1289 +1,18 @@ -import { useState, useRef, useEffect } from 'react' -import { useTranslation, type Locale } from '@/lib/use-translation' -import { useAppGoBack } from '@/hooks/useAppGoBack' -import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' -import { VOICES, getFallbackVoices } from '@/lib/voices' -import { useAppContext } from '@/lib/app-context' -import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice' -import { - getStaticVoiceOptions, - readStoredVoiceSelection, - resolveSelectedVoiceBackend, - writeStoredVoiceBackendPreference, - writeStoredVoiceSelection, -} from '@/lib/voicePickerPreferences' -import { VOICE_BACKEND_LABELS } from '@hapi/protocol/voicePickerCatalog' -import type { VoiceBackendType } from '@hapi/protocol/voice' -import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' -import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize' -import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' -import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' -import { getSessionListStatusModeOptions, useSessionListStatusMode, type SessionListStatusMode } from '@/hooks/useSessionListStatusMode' -import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' -import { - MAX_SESSION_PREVIEW_LIMIT, - MIN_SESSION_PREVIEW_LIMIT, - normalizeSessionPreviewLimit, - useSessionPreviewLimit, -} from '@/hooks/useSessionPreviewLimit' -import { - getChatSurfaceColorPickerValue, - getChatSurfaceColorPresetOptions, - toCustomChatSurfaceColorPreference, - toPresetChatSurfaceColorPreference, - useChatSurfaceColors, - type ChatSurfaceColorPreference, - type ChatSurfaceColorPreset, -} from '@/hooks/useChatSurfaceColors' -import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' -import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' -import { PROTOCOL_VERSION } from '@hapi/protocol' -import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls' +import { useTranslation } from '@/lib/use-translation' +import { SettingsNav } from '@/components/settings/SettingsNav' +import SettingsDisplayPage from './display' -const locales: { value: Locale; nativeLabel: string }[] = [ - { value: 'en', nativeLabel: 'English' }, - { value: 'zh-CN', nativeLabel: '简体中文' }, -] - -const voiceLanguages = getElevenLabsSupportedLanguages() - -function BackIcon(props: { className?: string }) { +export default function SettingsHubPage() { + const { t } = useTranslation() return ( - - - - ) -} - -function CheckIcon(props: { className?: string }) { - return ( - - - - ) -} - -function ChevronDownIcon(props: { className?: string }) { - return ( - - - - ) -} - -function PlayIcon(props: { className?: string }) { - return ( - - - - ) -} - -function StopIcon(props: { className?: string }) { - return ( - - - - ) -} - -function MinusIcon(props: { className?: string }) { - return ( - - - - ) -} - -function PlusIcon(props: { className?: string }) { - return ( - - - - - ) -} - -function SessionPreviewLimitControl(props: { - label: string - value: number - onChange: (value: number) => void - decreaseLabel: string - increaseLabel: string -}) { - const [draft, setDraft] = useState(String(props.value)) - - useEffect(() => { - setDraft(String(props.value)) - }, [props.value]) - - const commitDraft = () => { - const parsed = draft.trim() === '' ? props.value : Number(draft) - const next = normalizeSessionPreviewLimit(parsed) - props.onChange(next) - setDraft(String(next)) - } - - const step = (delta: number) => { - const next = normalizeSessionPreviewLimit(props.value + delta) - props.onChange(next) - setDraft(String(next)) - } - - return ( -
- -
- - setDraft(event.target.value)} - onBlur={commitDraft} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - commitDraft() - event.currentTarget.blur() - } - if (event.key === 'Escape') { - event.preventDefault() - setDraft(String(props.value)) - event.currentTarget.blur() - } - }} - className="h-8 w-14 border-x border-[var(--app-border)] bg-transparent text-center text-sm font-medium tabular-nums text-[var(--app-fg)] outline-none focus:bg-[var(--app-subtle-bg)]" - /> - + <> +
+
{t('settings.hub.description')}
+
-
- ) -} - -function ChatSurfaceColorControl(props: { - label: string - preference: ChatSurfaceColorPreference - onPresetChange: (preset: ChatSurfaceColorPreset) => void - onCustomChange: (value: string) => void - t: (key: string) => string -}) { - const presetOptions = getChatSurfaceColorPresetOptions() - const pickerValue = getChatSurfaceColorPickerValue(props.preference) - const isCustomSelected = props.preference.startsWith('custom:') - - return ( -
-
{props.label}
-
- {presetOptions.map((option) => { - const selected = props.preference === toPresetChatSurfaceColorPreference(option.value) - const swatchColor = getChatSurfaceColorPickerValue(toPresetChatSurfaceColorPreference(option.value)) - return ( - - ) - })} +
+
-
- {props.t('settings.chat.surfaceColor.custom')} - -
-
- ) -} - -function ThemeColorControl(props: { t: (key: string) => string }) { - const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors() - - return ( -
-
- {props.t('settings.display.themeColors.title')} - {hasAnyCustom && ( - - )} -
-
{props.t('settings.display.themeColors.description')}
-
- {keys.map((key) => { - const value = getPickerValue(key.id) - const customized = isCustomized(key.id) - return ( -
- {props.t(key.labelKey)} -
- {customized && ( - - )} - -
-
- ) - })} -
-
- ) -} - -export default function SettingsPage() { - const { t, locale, setLocale } = useTranslation() - const { api } = useAppContext() - const goBack = useAppGoBack() - const [isOpen, setIsOpen] = useState(false) - const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) - const [isFontOpen, setIsFontOpen] = useState(false) - const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false) - const [isChatOpen, setIsChatOpen] = useState(false) - const [isTerminalToolDisplayOpen, setIsTerminalToolDisplayOpen] = useState(false) - const [isSessionListStatusOpen, setIsSessionListStatusOpen] = useState(false) - const [isVoiceOpen, setIsVoiceOpen] = useState(false) - const [isVoiceBackendOpen, setIsVoiceBackendOpen] = useState(false) - const [isVoicePickerOpen, setIsVoicePickerOpen] = useState(false) - const containerRef = useRef(null) - const appearanceContainerRef = useRef(null) - const fontContainerRef = useRef(null) - const terminalFontContainerRef = useRef(null) - const chatContainerRef = useRef(null) - const terminalToolDisplayContainerRef = useRef(null) - const sessionListStatusContainerRef = useRef(null) - const voiceContainerRef = useRef(null) - const voiceBackendPickerRef = useRef(null) - const voicePickerContainerRef = useRef(null) - const { fontScale, setFontScale } = useFontScale() - const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() - const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit() - const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() - const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() - const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() - const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() - const { - toolGroupBackground, - userMessageBackground, - setToolGroupBackground, - setUserMessageBackground, - } = useChatSurfaceColors() - const { appearance, setAppearance } = useAppearance() - - // Voice language state - read from localStorage - const [voiceLanguage, setVoiceLanguage] = useState(() => { - return localStorage.getItem('hapi-voice-lang') - }) - - const [configuredVoiceBackends, setConfiguredVoiceBackends] = useState([]) - const [voiceBackend, setVoiceBackend] = useState(null) - - // Per-backend voice selection (localStorage keys differ by backend) - const [voiceId, setVoiceId] = useState(null) - - // Dynamic voice list fetched from hub (ElevenLabs only) - const [dynamicVoices, setDynamicVoices] = useState(null) - const [playingVoiceId, setPlayingVoiceId] = useState(null) - const currentAudioRef = useRef(null) - - // Voice opening mode — "brief" = proactive summary on connect, "greet" = greeting + wait - const [voiceOpening, setVoiceOpening] = useState<'greet' | 'brief'>(() => { - return localStorage.getItem('hapi-voice-proactive') === 'true' ? 'brief' : 'greet' - }) - - const handleVoiceOpeningChange = (value: 'greet' | 'brief') => { - setVoiceOpening(value) - if (value === 'brief') { - localStorage.setItem('hapi-voice-proactive', 'true') - } else { - localStorage.removeItem('hapi-voice-proactive') - } - } - - const fontScaleOptions = getFontScaleOptions() - const terminalFontSizeOptions = getTerminalFontSizeOptions() - const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions() - const terminalToolDisplayModeOptions = getTerminalToolDisplayModeOptions() - const sessionListStatusModeOptions = getSessionListStatusModeOptions() - const appearanceOptions = getAppearanceOptions() - const currentLocale = locales.find((loc) => loc.value === locale) - const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' - const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' - const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px' - const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send' - const currentTerminalToolDisplayModeLabel = terminalToolDisplayModeOptions.find((opt) => opt.value === terminalToolDisplayMode)?.labelKey ?? 'settings.chat.terminalToolDisplay.compact' - const currentSessionListStatusModeLabel = sessionListStatusModeOptions.find((opt) => opt.value === sessionListStatusMode)?.labelKey ?? 'settings.display.sessionListStatus.standard' - const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) - - const staticVoiceOptions = voiceBackend ? getStaticVoiceOptions(voiceBackend) : [] - - // Voice list: ElevenLabs dynamic + fallback, or static catalog for Gemini/Qwen - const fallbackVoices = getFallbackVoices(locale) - const voiceOptions: VoiceInfo[] = voiceBackend === 'elevenlabs' - ? (dynamicVoices && dynamicVoices.length > 0 - ? dynamicVoices - : fallbackVoices.map(v => ({ id: v.id, name: v.name, previewUrl: '', category: 'premade' }))) - : voiceBackend === 'gemini-live' || voiceBackend === 'qwen-realtime' - ? staticVoiceOptions.map(v => ({ - id: v.id, - name: v.label, - description: v.description, - previewUrl: '', - category: 'premade' - })) - : fallbackVoices.map(v => ({ id: v.id, name: v.name, previewUrl: '', category: 'premade' })) - - const currentVoiceName = voiceId - ? (voiceOptions.find(v => v.id === voiceId)?.name - ?? staticVoiceOptions.find(v => v.id === voiceId)?.label - ?? fallbackVoices.find(v => v.id === voiceId)?.name - ?? voiceId) - : null - const currentVoiceDescription = voiceId - ? (voiceOptions.find(v => v.id === voiceId)?.description - ?? staticVoiceOptions.find(v => v.id === voiceId)?.description) - : undefined - const staticVoiceCatalog = voiceBackend === 'gemini-live' || voiceBackend === 'qwen-realtime' - - const voicePreviewEnabled = voiceBackend === 'elevenlabs' - const showVoiceBackendChooser = configuredVoiceBackends.length > 1 - const currentVoiceBackendLabel = voiceBackend ? VOICE_BACKEND_LABELS[voiceBackend] : null - - const handleLocaleChange = (newLocale: Locale) => { - setLocale(newLocale) - setIsOpen(false) - } - - const handleAppearanceChange = (pref: AppearancePreference) => { - setAppearance(pref) - setIsAppearanceOpen(false) - } - - const handleFontScaleChange = (newScale: FontScale) => { - setFontScale(newScale) - setIsFontOpen(false) - } - - const handleTerminalFontSizeChange = (newSize: TerminalFontSize) => { - setTerminalFontSize(newSize) - setIsTerminalFontOpen(false) - } - - const handleComposerEnterBehaviorChange = (newBehavior: ComposerEnterBehavior) => { - setComposerEnterBehavior(newBehavior) - setIsChatOpen(false) - } - - const handleTerminalToolDisplayModeChange = (newMode: TerminalToolDisplayMode) => { - setTerminalToolDisplayMode(newMode) - setIsTerminalToolDisplayOpen(false) - } - - const handleSessionListStatusModeChange = (newMode: SessionListStatusMode) => { - setSessionListStatusMode(newMode) - setIsSessionListStatusOpen(false) - } - - const handleVoiceLanguageChange = (language: Language) => { - setVoiceLanguage(language.code) - if (language.code === null) { - localStorage.removeItem('hapi-voice-lang') - } else { - localStorage.setItem('hapi-voice-lang', language.code) - } - setIsVoiceOpen(false) - } - - const handleVoiceChange = (id: string | null) => { - setVoiceId(id) - writeStoredVoiceSelection(voiceBackend ?? 'elevenlabs', id) - setIsVoicePickerOpen(false) - } - - const handleVoiceBackendChange = (backend: VoiceBackendType) => { - writeStoredVoiceBackendPreference(backend) - setVoiceBackend(backend) - setVoiceId(readStoredVoiceSelection(backend)) - setIsVoiceBackendOpen(false) - } - - // Resolve configured backends, user preference, and voice selection for that backend - useEffect(() => { - let cancelled = false - fetchVoiceBackend(api).then((resp) => { - if (cancelled) return - setConfiguredVoiceBackends(resp.backends) - const selected = resolveSelectedVoiceBackend(resp.backends, resp.backend) - setVoiceBackend(selected) - setVoiceId(readStoredVoiceSelection(selected)) - }).catch(() => { - if (cancelled) return - setConfiguredVoiceBackends(['elevenlabs']) - setVoiceBackend('elevenlabs') - setVoiceId(readStoredVoiceSelection('elevenlabs')) - }) - return () => { cancelled = true } - }, [api]) - - // Fetch ElevenLabs voices only after hub reports elevenlabs backend - useEffect(() => { - if (voiceBackend !== 'elevenlabs') { - setDynamicVoices(null) - return - } - let cancelled = false - fetchVoices(api).then(voices => { - if (!cancelled && voices.length > 0) setDynamicVoices(voices) - }) - return () => { cancelled = true } - }, [api, voiceBackend]) - - const handleVoicePreview = (previewUrl: string, voiceId: string, event: React.MouseEvent) => { - event.stopPropagation() - if (!previewUrl) return - - if (playingVoiceId === voiceId) { - currentAudioRef.current?.pause() - currentAudioRef.current = null - setPlayingVoiceId(null) - return - } - - currentAudioRef.current?.pause() - const audio = new Audio(previewUrl) - currentAudioRef.current = audio - setPlayingVoiceId(voiceId) - audio.play().catch(() => setPlayingVoiceId(null)) - audio.addEventListener('ended', () => { - setPlayingVoiceId(null) - currentAudioRef.current = null - }) - } - - useEffect(() => { - return () => { - currentAudioRef.current?.pause() - currentAudioRef.current = null - setPlayingVoiceId(null) - } - }, []) - - // Close dropdown when clicking outside - useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - } - if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) { - setIsAppearanceOpen(false) - } - if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) { - setIsFontOpen(false) - } - if (isTerminalFontOpen && terminalFontContainerRef.current && !terminalFontContainerRef.current.contains(event.target as Node)) { - setIsTerminalFontOpen(false) - } - if (isChatOpen && chatContainerRef.current && !chatContainerRef.current.contains(event.target as Node)) { - setIsChatOpen(false) - } - if (isTerminalToolDisplayOpen && terminalToolDisplayContainerRef.current && !terminalToolDisplayContainerRef.current.contains(event.target as Node)) { - setIsTerminalToolDisplayOpen(false) - } - if (isSessionListStatusOpen && sessionListStatusContainerRef.current && !sessionListStatusContainerRef.current.contains(event.target as Node)) { - setIsSessionListStatusOpen(false) - } - if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) { - setIsVoiceOpen(false) - } - if (isVoiceBackendOpen && voiceBackendPickerRef.current && !voiceBackendPickerRef.current.contains(event.target as Node)) { - setIsVoiceBackendOpen(false) - } - if (isVoicePickerOpen && voicePickerContainerRef.current && !voicePickerContainerRef.current.contains(event.target as Node)) { - setIsVoicePickerOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen]) - - // Close on escape key - useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsOpen(false) - setIsAppearanceOpen(false) - setIsFontOpen(false) - setIsTerminalFontOpen(false) - setIsChatOpen(false) - setIsTerminalToolDisplayOpen(false) - setIsSessionListStatusOpen(false) - setIsVoiceOpen(false) - setIsVoiceBackendOpen(false) - setIsVoicePickerOpen(false) - } - } - - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen]) - - return ( -
-
-
- -
{t('settings.title')}
-
-
- -
-
- {/* Language section */} -
-
- {t('settings.language.title')} -
-
- - - {isOpen && ( -
- {locales.map((loc) => { - const isSelected = locale === loc.value - return ( - - ) - })} -
- )} -
-
- - {/* Display section */} -
-
- {t('settings.display.title')} -
-
- - - {isAppearanceOpen && ( -
- {appearanceOptions.map((opt) => { - const isSelected = appearance === opt.value - return ( - - ) - })} -
- )} -
- -
- - - {isFontOpen && ( -
- {fontScaleOptions.map((opt) => { - const isSelected = fontScale === opt.value - return ( - - ) - })} -
- )} -
-
- - - {isTerminalFontOpen && ( -
- {terminalFontSizeOptions.map((opt) => { - const isSelected = terminalFontSize === opt.value - return ( - - ) - })} -
- )} -
- -
-
- {t('settings.display.activeSessionsOnly')} - {t('settings.display.activeSessionsOnly.desc')} -
- -
-
- - - {isSessionListStatusOpen && ( -
- {sessionListStatusModeOptions.map((opt) => { - const isSelected = sessionListStatusMode === opt.value - return ( - - ) - })} -
- )} -
- {sessionListStatusMode === 'detailed' ? ( -
- {t('settings.display.sessionListStatus.detailedDescription')} -
- ) : null} -
- - {/* Chat section */} -
-
- {t('settings.chat.title')} -
-
- - - {isChatOpen && ( -
- {composerEnterBehaviorOptions.map((opt) => { - const isSelected = composerEnterBehavior === opt.value - return ( - - ) - })} -
- )} -
-
- - - {isTerminalToolDisplayOpen && ( -
- {terminalToolDisplayModeOptions.map((opt) => { - const isSelected = terminalToolDisplayMode === opt.value - return ( - - ) - })} -
- )} -
- setToolGroupBackground(toPresetChatSurfaceColorPreference(preset))} - onCustomChange={(value) => setToolGroupBackground(toCustomChatSurfaceColorPreference(value))} - t={t} - /> - setUserMessageBackground(toPresetChatSurfaceColorPreference(preset))} - onCustomChange={(value) => setUserMessageBackground(toCustomChatSurfaceColorPreference(value))} - t={t} - /> -
- - {/* Voice Assistant section */} -
-
- {t('settings.voice.title')} -
- - {/* ── Connection & provider ── */} -
-
- {t('settings.voice.connection.title')} -
- {showVoiceBackendChooser && ( -
- - {isVoiceBackendOpen && ( -
- {configuredVoiceBackends.map((backend) => { - const isSelected = voiceBackend === backend - return ( - - ) - })} -
- )} -
- )} -
- - {isVoiceOpen && ( -
- {voiceLanguages.map((lang) => { - const isSelected = voiceLanguage === lang.code - const displayName = lang.code === null ? t('settings.voice.autoDetect') : getLanguageDisplayName(lang) - return ( - - ) - })} -
- )} -
-
- - {staticVoiceCatalog &&

{t('settings.voice.staticCatalogHint')}

} - {isVoicePickerOpen && ( -
-
- -
- {voiceOptions.map((voice) => { - const isSelected = voiceId === voice.id - const isPlaying = playingVoiceId === voice.id - return ( -
- - -
- ) - })} -
- )} -
-
- - {/* ── How It Sounds ── */} -
-
- {t('settings.voice.sounds.title')} -
- -
- - {/* ── How it behaves ── */} -
-
- {t('settings.voice.behaves.title')} -
- {/* Opening */} -
-

{t('settings.voice.opening.label')}

-
- {(['greet', 'brief'] as const).map((opt) => ( - - ))} -
-
- -
- - {/* ── Persona & instructions ── */} -
-
- {t('settings.voice.persona.title')} -
- -
- - {/* ── Advanced ── */} -
-
- {t('settings.voice.advanced.section.title')} -
- -
-
- - {/* About section */} -
-
- {t('settings.about.title')} -
-
- {t('settings.about.website')} - - hapi.run - -
-
- {t('settings.about.appVersion')} - {__APP_VERSION__} -
-
- {t('settings.about.protocolVersion')} - {PROTOCOL_VERSION} -
-
-
-
-
+ ) } diff --git a/web/src/routes/settings/layout.tsx b/web/src/routes/settings/layout.tsx new file mode 100644 index 00000000..e0fc8e98 --- /dev/null +++ b/web/src/routes/settings/layout.tsx @@ -0,0 +1,57 @@ +import { Outlet, useLocation, useNavigate } from '@tanstack/react-router' +import { useTranslation } from '@/lib/use-translation' +import { useAppGoBack } from '@/hooks/useAppGoBack' +import { SettingsNav } from '@/components/settings/SettingsNav' +import { getSettingsCategory } from './categories' + +function BackIcon() { + return ( + + ) +} + +export default function SettingsLayout() { + const { t } = useTranslation() + const goBack = useAppGoBack() + const navigate = useNavigate() + const pathname = useLocation({ select: (location) => location.pathname }) + const category = getSettingsCategory(pathname) + const mobileTitleKey = pathname === '/settings/voice/voices' + ? 'settings.voice.voice' + : pathname === '/settings/voice/advanced' + ? 'settings.voice.advanced.title' + : category?.titleKey ?? 'settings.title' + const mobileTitle = t(mobileTitleKey) + + return ( +
+
+
+ + +
+

{mobileTitle}

+ {t('settings.title')} +
+
+
+ +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/web/src/routes/settings/useVoiceSettings.test.tsx b/web/src/routes/settings/useVoiceSettings.test.tsx new file mode 100644 index 00000000..a7ab41b6 --- /dev/null +++ b/web/src/routes/settings/useVoiceSettings.test.tsx @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { useVoiceSettings } from './useVoiceSettings' + +const { fetchVoiceBackend, fetchVoices, pause, play } = vi.hoisted(() => ({ + fetchVoiceBackend: vi.fn(), + fetchVoices: vi.fn(), + pause: vi.fn(), + play: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: {} }), +})) + +vi.mock('@/api/voice', () => ({ + fetchVoiceBackend, + fetchVoices, +})) + +function Wrapper(props: { children: React.ReactNode }) { + return {props.children} +} + +describe('useVoiceSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + fetchVoiceBackend.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) + fetchVoices.mockResolvedValue([]) + class MockAudio { + constructor(_source: string) {} + play = play + pause = pause + addEventListener = vi.fn() + } + vi.stubGlobal('Audio', MockAudio) + }) + + it('loads dynamic voices and keeps the existing per-backend storage key', async () => { + fetchVoices.mockResolvedValue([ + { id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ]) + const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1')) + act(() => result.current.setVoice('dynamic-1')) + + expect(localStorage.getItem('hapi-voice-elevenlabs')).toBe('dynamic-1') + }) + + it('switches configured backends and restores the backend-specific selection', async () => { + localStorage.setItem('hapi-voice-elevenlabs', 'eleven-1') + fetchVoiceBackend.mockResolvedValue({ + backend: 'gemini-live', + backends: ['gemini-live', 'elevenlabs'], + }) + const { result } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.backend).toBe('gemini-live')) + act(() => result.current.setBackend('elevenlabs')) + + expect(result.current.backend).toBe('elevenlabs') + expect(result.current.voiceId).toBe('eleven-1') + expect(localStorage.getItem('hapi-voice-backend')).toBe('elevenlabs') + }) + + it('stops a playing preview when the picker unmounts', async () => { + fetchVoices.mockResolvedValue([ + { id: 'dynamic-1', name: 'Dynamic Voice', previewUrl: 'https://example.test/voice.mp3', category: 'premade' }, + ]) + const { result, unmount } = renderHook(() => useVoiceSettings(), { wrapper: Wrapper }) + await waitFor(() => expect(result.current.voices[0]?.id).toBe('dynamic-1')) + + act(() => result.current.previewVoice(result.current.voices[0])) + expect(play).toHaveBeenCalledOnce() + unmount() + expect(pause).toHaveBeenCalled() + }) +}) diff --git a/web/src/routes/settings/useVoiceSettings.ts b/web/src/routes/settings/useVoiceSettings.ts new file mode 100644 index 00000000..1ebcd261 --- /dev/null +++ b/web/src/routes/settings/useVoiceSettings.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import { useTranslation } from '@/lib/use-translation' +import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice' +import { getFallbackVoices } from '@/lib/voices' +import { getElevenLabsSupportedLanguages, type Language } from '@/lib/languages' +import { + getStaticVoiceOptions, + readStoredVoiceSelection, + resolveSelectedVoiceBackend, + writeStoredVoiceBackendPreference, + writeStoredVoiceSelection, +} from '@/lib/voicePickerPreferences' +import type { VoiceBackendType } from '@hapi/protocol/voice' + +export function useVoiceSettings() { + const { api } = useAppContext() + const { locale } = useTranslation() + const [configuredBackends, setConfiguredBackends] = useState([]) + const [backend, setBackendState] = useState(null) + const [voiceId, setVoiceIdState] = useState(null) + const [dynamicVoices, setDynamicVoices] = useState(null) + const [voiceLanguage, setVoiceLanguageState] = useState(() => localStorage.getItem('hapi-voice-lang')) + const [playingVoiceId, setPlayingVoiceId] = useState(null) + const audioRef = useRef(null) + + useEffect(() => { + let cancelled = false + fetchVoiceBackend(api).then((response) => { + if (cancelled) return + setConfiguredBackends(response.backends) + const selected = resolveSelectedVoiceBackend(response.backends, response.backend) + setBackendState(selected) + setVoiceIdState(readStoredVoiceSelection(selected)) + }).catch(() => { + if (cancelled) return + setConfiguredBackends(['elevenlabs']) + setBackendState('elevenlabs') + setVoiceIdState(readStoredVoiceSelection('elevenlabs')) + }) + return () => { cancelled = true } + }, [api]) + + useEffect(() => { + if (backend !== 'elevenlabs') { + setDynamicVoices(null) + return + } + let cancelled = false + fetchVoices(api).then((voices) => { + if (!cancelled) setDynamicVoices(voices.length > 0 ? voices : null) + }).catch(() => { + if (!cancelled) setDynamicVoices(null) + }) + return () => { cancelled = true } + }, [api, backend]) + + const voices = useMemo(() => { + const fallback = getFallbackVoices(locale) + if (backend === 'elevenlabs') { + return dynamicVoices ?? fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' })) + } + if (backend === 'gemini-live' || backend === 'qwen-realtime') { + return getStaticVoiceOptions(backend).map((voice) => ({ id: voice.id, name: voice.label, description: voice.description, previewUrl: '', category: 'premade' })) + } + return fallback.map((voice) => ({ id: voice.id, name: voice.name, previewUrl: '', category: 'premade' })) + }, [backend, dynamicVoices, locale]) + + const setBackend = useCallback((value: VoiceBackendType) => { + writeStoredVoiceBackendPreference(value) + setBackendState(value) + setVoiceIdState(readStoredVoiceSelection(value)) + }, []) + + const setVoice = useCallback((value: string | null) => { + setVoiceIdState(value) + writeStoredVoiceSelection(backend ?? 'elevenlabs', value) + }, [backend]) + + const setVoiceLanguage = useCallback((language: Language) => { + setVoiceLanguageState(language.code) + if (language.code === null) localStorage.removeItem('hapi-voice-lang') + else localStorage.setItem('hapi-voice-lang', language.code) + }, []) + + const stopPreview = useCallback(() => { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + }, []) + + const previewVoice = useCallback((voice: VoiceInfo) => { + if (!voice.previewUrl || backend !== 'elevenlabs') return + if (playingVoiceId === voice.id) { + stopPreview() + return + } + stopPreview() + const audio = new Audio(voice.previewUrl) + audioRef.current = audio + setPlayingVoiceId(voice.id) + audio.play().catch(stopPreview) + audio.addEventListener('ended', stopPreview, { once: true }) + }, [backend, playingVoiceId, stopPreview]) + + useEffect(() => stopPreview, [stopPreview]) + + return { + configuredBackends, + backend, + setBackend, + voiceId, + setVoice, + voices, + voiceLanguage, + setVoiceLanguage, + voiceLanguages: getElevenLabsSupportedLanguages(), + playingVoiceId, + previewVoice, + } +} diff --git a/web/src/routes/settings/voice-advanced.tsx b/web/src/routes/settings/voice-advanced.tsx new file mode 100644 index 00000000..078eae50 --- /dev/null +++ b/web/src/routes/settings/voice-advanced.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from '@/lib/use-translation' +import { VoiceDiagnosticsControls, VoicePersonaControls, VoiceSoundsControls } from '@/components/settings/VoiceAdvancedControls' +import { SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +export default function SettingsVoiceAdvancedPage() { + const { t } = useTranslation() + const { backend } = useVoiceSettings() + return ( + + + + + + + + + + + + ) +} diff --git a/web/src/routes/settings/voice-voices.tsx b/web/src/routes/settings/voice-voices.tsx new file mode 100644 index 00000000..90b5784c --- /dev/null +++ b/web/src/routes/settings/voice-voices.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from '@/lib/use-translation' +import { CheckIcon, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +function PlayIcon() { + return +} + +function StopIcon() { + return +} + +export default function SettingsVoiceVoicesPage() { + const { t } = useTranslation() + const voice = useVoiceSettings() + return ( + + +
+ + {voice.voices.map((option) => { + const selected = voice.voiceId === option.id + const playing = voice.playingVoiceId === option.id + const canPreview = voice.backend === 'elevenlabs' && Boolean(option.previewUrl) + return ( +
+ + +
+ ) + })} +
+
+
+ ) +} diff --git a/web/src/routes/settings/voice.tsx b/web/src/routes/settings/voice.tsx new file mode 100644 index 00000000..70240bca --- /dev/null +++ b/web/src/routes/settings/voice.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { VOICE_BACKEND_LABELS } from '@hapi/protocol/voicePickerCatalog' +import { getLanguageDisplayName } from '@/lib/languages' +import { useTranslation } from '@/lib/use-translation' +import { VoiceRespondsControls } from '@/components/settings/VoiceAdvancedControls' +import { SettingsChoiceGroup, SettingsLinkRow, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useVoiceSettings } from './useVoiceSettings' + +export default function SettingsVoicePage() { + const { t } = useTranslation() + const navigate = useNavigate() + const voice = useVoiceSettings() + const [opening, setOpening] = useState<'greet' | 'brief'>(() => localStorage.getItem('hapi-voice-proactive') === 'true' ? 'brief' : 'greet') + const selectedLanguage = voice.voiceLanguages.find((language) => language.code === voice.voiceLanguage) + const selectedVoice = voice.voices.find((option) => option.id === voice.voiceId) + + const setVoiceOpening = (value: 'greet' | 'brief') => { + setOpening(value) + if (value === 'brief') localStorage.setItem('hapi-voice-proactive', 'true') + else localStorage.removeItem('hapi-voice-proactive') + } + + return ( + + + {voice.configuredBackends.length > 1 && voice.backend ? ( + ({ value: backend, label: VOICE_BACKEND_LABELS[backend] }))} + onChange={voice.setBackend} + /> + ) : null} + + navigate({ to: '/settings/voice/voices' })} + /> + + + + ({ value, label: t(`settings.voice.opening.${value}`), description: t(`settings.voice.opening.${value}.hint`) }))} + onChange={setVoiceOpening} + /> + + + + navigate({ to: '/settings/voice/advanced' })} /> + + + ) +}