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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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.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 (
+
+ )
+}
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' })} />
+
+
+ )
+}